Signal drop!
Relay (operand.online) is unreachable.
Usually, a dropped signal means an upgrade is happening. Hold on!
Sorry, no connección.
Hang in there while we get back on track
gram: essay
> ./src/tee/utils.ts
Lenses
(coming soon!)
import {
CommentThreadForUI,
CommentThreadWithPosition,
MarkdownDoc,
} from "./schema";
import { EditorView } from "@codemirror/view";
import { next as A } from "@automerge/automerge";
import { ReactElement, useEffect, useMemo, useState } from "react";
import ReactDOMServer from "react-dom/server";
// taken from https://www.builder.io/blog/relative-time
/**
* Convert a date to a relative time string, such as
* "a minute ago", "in 2 hours", "yesterday", "3 months ago", etc.
* using Intl.RelativeTimeFormat
*/
export function getRelativeTimeString(
date: Date | number,
lang = navigator.language
): string {
// Allow dates or times to be passed
const timeMs = typeof date === "number" ? date : date.getTime();
// Get the amount of seconds between the given date and now
const deltaSeconds = Math.round((timeMs - Date.now()) / 1000);
// Array reprsenting one minute, hour, day, week, month, etc in seconds
const cutoffs = [
60,
3600,
86400,
86400 * 7,
86400 * 30,
86400 * 365,
Infinity,
];
// Array equivalent to the above but in the string representation of the units
const units: Intl.RelativeTimeFormatUnit[] = [
"second",
"minute",
"hour",
"day",
"week",
"month",
"year",
];
// Grab the ideal cutoff unit
const unitIndex = cutoffs.findIndex(
(cutoff) => cutoff > Math.abs(deltaSeconds)
);
// Get the divisor to divide from the seconds. E.g. if our unit is "day" our divisor
// is one day in seconds, so we can divide our seconds by this to get the # of days
const divisor = unitIndex ? cutoffs[unitIndex - 1] : 1;
// Intl.RelativeTimeFormat do its magic
const rtf = new Intl.RelativeTimeFormat(lang, { numeric: "auto" });
return rtf.format(Math.floor(deltaSeconds / divisor), units[unitIndex]);
}
// a very rough approximation; needs to be better but being perfect seems hard
const estimatedHeightOfThread = (thread: CommentThreadForUI) => {
const commentHeights = thread.comments.map(
(comment) => 64 + Math.floor(comment.content.length / 60) * 20
);
const commentsHeight = commentHeights.reduce((a, b) => a + b, 0);
const PADDING = 32;
const BUTTONS = 40;
return PADDING + BUTTONS + commentsHeight + 20;
};
// Resolve comment thread cursors to integer positions in the document
export const getThreadsForUI = (
doc: MarkdownDoc,
activeThreadId: string | null
): CommentThreadForUI[] => {
return Object.values(doc.commentThreads ?? {})
.filter((thread) => !thread.resolved) // hide resolved threads
.flatMap((thread) => {
let from = 0;
let to = 0;
try {
from = A.getCursorPosition(doc, ["content"], thread.fromCursor);
to = A.getCursorPosition(doc, ["content"], thread.toCursor);
} catch (e) {
if (e instanceof RangeError) {
// If the cursor isn't found in the content string, hide the comment.
// This does *not* occur if the comment is pointing to deleted text!
// It only happens if the string at /content has been replaced entirely.
return [];
} else {
throw e;
}
}
return [
{
...thread,
from,
to,
active: thread.id === activeThreadId,
},
];
})
.filter(
(thread) => thread.to > thread.from // hide threads pointing to deleted text
);
};
// Given a list of comment threads, find a vertical position for each comment.
// We roughly try to put the comments vertically near the text they are commenting on.
// But we also avoid overlapping comments by bumping them up or down if they overlap.
// The currently active comment gets priority for being nearby its text;
// other comments bump up or down from that point.
export const getVisibleTheadsWithPos = ({
threads,
doc,
view,
activeThreadId,
}: {
threads: CommentThreadForUI[];
doc: MarkdownDoc;
view: EditorView;
activeThreadId: string | null;
}): CommentThreadWithPosition[] => {
// As an initial draft, put each thread right next to its comment
const threadsWithPositions = threads.flatMap((thread) => {
const topOfEditor = view?.scrollDOM.getBoundingClientRect()?.top ?? 0;
const viewportCoordsOfThread = view?.coordsAtPos(
Math.min(thread.from, doc.content.length - 1)
)?.top;
if (viewportCoordsOfThread === undefined) {
return [];
}
const TOP_MARGIN = 40;
const yCoord = -1 * topOfEditor + viewportCoordsOfThread + TOP_MARGIN - 16;
return [
{
...thread,
yCoord,
},
];
});
// Sort the draft by vertical position in the doc
threadsWithPositions.sort((a, b) => a.from - b.from);
// Now it's possible that we have comments which are overlapping one another.
// Make a best effort to mostly avoid overlaps.
let activeIndex = threadsWithPositions.findIndex(
(thread) => thread.id === activeThreadId
);
if (activeIndex === -1) activeIndex = 0;
// Iterate upwards
for (let i = activeIndex - 1; i >= 0; i--) {
if (
threadsWithPositions[i].yCoord +
estimatedHeightOfThread(threadsWithPositions[i]) >
threadsWithPositions[i + 1].yCoord
) {
threadsWithPositions[i].yCoord =
threadsWithPositions[i + 1].yCoord -
estimatedHeightOfThread(threadsWithPositions[i]);
}
}
// Iterate downwards
for (let i = activeIndex + 1; i < threadsWithPositions.length; i++) {
if (
threadsWithPositions[i].yCoord <
threadsWithPositions[i - 1].yCoord +
estimatedHeightOfThread(threadsWithPositions[i - 1])
) {
threadsWithPositions[i].yCoord =
threadsWithPositions[i - 1].yCoord +
estimatedHeightOfThread(threadsWithPositions[i - 1]);
}
}
for (let i = 1; i < threadsWithPositions.length; i++) {
if (
threadsWithPositions[i].yCoord <
threadsWithPositions[i - 1].yCoord +
estimatedHeightOfThread(threadsWithPositions[i - 1])
) {
if (threadsWithPositions[i].id === activeThreadId) {
threadsWithPositions[i - 1].yCoord =
threadsWithPositions[i].yCoord -
estimatedHeightOfThread(threadsWithPositions[i - 1]);
} else {
threadsWithPositions[i].yCoord =
threadsWithPositions[i - 1].yCoord +
estimatedHeightOfThread(threadsWithPositions[i - 1]);
}
}
}
return threadsWithPositions;
};
export const useScrollPosition = (
ref: React.MutableRefObject<HTMLElement | null>
) => {
const [scrollPosition, setScrollPosition] = useState(0);
useEffect(() => {
if (!ref.current) {
return;
}
const div = ref.current;
const updatePosition = () => {
setScrollPosition(div.scrollTop);
};
div.addEventListener("scroll", () => updatePosition());
updatePosition();
return () => div.removeEventListener("scroll", updatePosition);
}, [ref, ref.current]);
return scrollPosition;
};
// Utils for converting back and forth between CodeMirror and Automerge ranges.
// The end of a Codemirror range can be an index past the last character in the
// document, but we can't get an Automerge cursor for that position.
// TODO: understand and document this more thoroughly
export const cmRangeToAMRange = (range: { from: number; to: number }) => ({
from: range.from,
to: range.to - 1,
});
export const amRangeToCMRange = (range: { from: number; to: number }) => ({
from: range.from,
to: range.to + 1,
});
// Helper for making HTML Elements in codemirror editor
export const jsxToHtmlElement = (jsx: ReactElement): HTMLElement => {
const htmlString = ReactDOMServer.renderToStaticMarkup(jsx);
const div = document.createElement("div");
div.innerHTML = htmlString;
return div.firstElementChild as HTMLElement;
};
// A React hook that gets the comment threads for the doc w/ positions
// and manages caching.
export const useThreadsWithPositions = ({
doc,
view,
activeThreadId,
editorRef,
}: {
doc: MarkdownDoc;
view: EditorView;
activeThreadId: string;
editorRef: React.MutableRefObject<HTMLElement | null>;
}) => {
// We first get integer positions for each thread and cache that.
const threads = useMemo(
() => (doc ? getThreadsForUI(doc, activeThreadId) : []),
[doc, activeThreadId]
);
// Next we get the vertical position for each thread.
// It may be inefficient to rerender comments sidebar on each scroll but
// it's fine for now and it lets us reposition comments as the user scrolls.
// (Notably we're not repositioning comments in JS at 60fps;
// we just use CodeMirror to compute position, and it doesn't tell us position
// of comments that are way off-screen. That's why we need this scroll handler
// to catch when things come near the screen)
const scrollPosition = useScrollPosition(editorRef);
const threadsWithPositions = useMemo(
() =>
view
? getVisibleTheadsWithPos({ threads, doc, view, activeThreadId })
: [],
// the scrollPosition dependency is implicit so the linter thinks it's not needed;
// but actually it's critical for making comments appear correctly as scrolling happens
[doc, view, activeThreadId, threads, scrollPosition]
);
return threadsWithPositions;
};