import { type DiffLineAnnotation, type SelectedLineRange } from "@pierre/diffs" import { createEffect, createMemo, createSignal, onCleanup, Show, type Accessor, type JSX } from "solid-js" import { render as renderSolid } from "solid-js/web" import { createHoverCommentUtility } from "../pierre/comment-hover" import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge" import { LineComment, LineCommentEditor } from "./line-comment" export type LineCommentAnnotationMeta = | { kind: "comment"; key: string; comment: T } | { kind: "draft"; key: string; range: SelectedLineRange } export type LineCommentAnnotation = { lineNumber: number side?: "additions" | "deletions" metadata: LineCommentAnnotationMeta } type LineCommentAnnotationsProps = { comments: Accessor getCommentId: (comment: T) => string getCommentSelection: (comment: T) => SelectedLineRange draftRange: Accessor draftKey: Accessor } type LineCommentAnnotationsWithSideProps = LineCommentAnnotationsProps & { getSide: (range: SelectedLineRange) => "additions" | "deletions" } type HoverCommentLine = { lineNumber: number side?: "additions" | "deletions" } type LineCommentStateProps = { opened: Accessor setOpened: (id: T | null) => void selected: Accessor setSelected: (range: SelectedLineRange | null) => void commenting: Accessor setCommenting: (range: SelectedLineRange | null) => void syncSelected?: (range: SelectedLineRange | null) => void hoverSelected?: (range: SelectedLineRange) => void } type LineCommentShape = { id: string selection: SelectedLineRange comment: string } type LineCommentControllerProps = { comments: Accessor draftKey: Accessor label: string state: LineCommentStateProps onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void onDelete?: (comment: T) => void renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element editSubmitLabel?: string onDraftPopoverFocusOut?: JSX.EventHandlerUnion getHoverSelectedRange?: Accessor cancelDraftOnCommentToggle?: boolean clearSelectionOnSelectionEndNull?: boolean } type LineCommentControllerWithSideProps = LineCommentControllerProps & { getSide: (range: SelectedLineRange) => "additions" | "deletions" } type CommentProps = { id?: string open: boolean comment: JSX.Element selection: JSX.Element actions?: JSX.Element editor?: DraftProps onClick?: JSX.EventHandlerUnion onMouseEnter?: JSX.EventHandlerUnion } type DraftProps = { value: string selection: JSX.Element onInput: (value: string) => void onCancel: VoidFunction onSubmit: (value: string) => void onPopoverFocusOut?: JSX.EventHandlerUnion cancelLabel?: string submitLabel?: string } export function createLineCommentAnnotationRenderer(props: { renderComment: (comment: T) => CommentProps renderDraft: (range: SelectedLineRange) => DraftProps }) { const nodes = new Map< string, { host: HTMLDivElement dispose: VoidFunction setMeta: (meta: LineCommentAnnotationMeta) => void } >() const mount = (meta: LineCommentAnnotationMeta) => { if (typeof document === "undefined") return const host = document.createElement("div") host.setAttribute("data-prevent-autofocus", "") const [current, setCurrent] = createSignal(meta) const dispose = renderSolid(() => { const active = current() if (active.kind === "comment") { const view = createMemo(() => { const next = current() if (next.kind !== "comment") return props.renderComment(active.comment) return props.renderComment(next.comment) }) return ( } > ) } const view = createMemo(() => { const next = current() if (next.kind !== "draft") return props.renderDraft(active.range) return props.renderDraft(next.range) }) return ( ) }, host) const node = { host, dispose, setMeta: setCurrent } nodes.set(meta.key, node) return node } const render = }>(annotation: A) => { const meta = annotation.metadata const node = nodes.get(meta.key) ?? mount(meta) if (!node) return node.setMeta(meta) return node.host } const reconcile = }>(annotations: A[]) => { const next = new Set(annotations.map((annotation) => annotation.metadata.key)) for (const [key, node] of nodes) { if (next.has(key)) continue node.dispose() nodes.delete(key) } } const cleanup = () => { for (const [, node] of nodes) node.dispose() nodes.clear() } return { render, reconcile, cleanup } } export function createLineCommentState(props: LineCommentStateProps) { const [draft, setDraft] = createSignal("") const [editing, setEditing] = createSignal(null) const toRange = (range: SelectedLineRange | null) => (range ? cloneSelectedLineRange(range) : null) const setSelected = (range: SelectedLineRange | null) => { const next = toRange(range) props.setSelected(next) props.syncSelected?.(toRange(next)) return next } const setCommenting = (range: SelectedLineRange | null) => { const next = toRange(range) props.setCommenting(next) return next } const closeComment = () => { props.setOpened(null) } const cancelDraft = () => { setDraft("") setEditing(null) setCommenting(null) } const reset = () => { setDraft("") setEditing(null) props.setOpened(null) props.setSelected(null) props.setCommenting(null) } const openComment = (id: T, range: SelectedLineRange, options?: { cancelDraft?: boolean }) => { if (options?.cancelDraft) cancelDraft() props.setOpened(id) setSelected(range) } const toggleComment = (id: T, range: SelectedLineRange, options?: { cancelDraft?: boolean }) => { if (options?.cancelDraft) cancelDraft() const next = props.opened() === id ? null : id props.setOpened(next) setSelected(range) } const openDraft = (range: SelectedLineRange) => { const next = toRange(range) setDraft("") setEditing(null) closeComment() setSelected(next) setCommenting(next) } const openEditor = (id: T, range: SelectedLineRange, value: string) => { closeComment() setSelected(range) props.setCommenting(null) setEditing(() => id) setDraft(value) } const hoverComment = (range: SelectedLineRange) => { const next = toRange(range) if (!next) return if (props.hoverSelected) { props.hoverSelected(next) return } setSelected(next) } const finishSelection = (range: SelectedLineRange) => { closeComment() setSelected(range) cancelDraft() } createEffect(() => { props.commenting() setDraft("") }) return { draft, setDraft, editing, opened: props.opened, selected: props.selected, commenting: props.commenting, isOpen: (id: T) => props.opened() === id, isEditing: (id: T) => editing() === id, closeComment, openComment, toggleComment, openDraft, openEditor, hoverComment, cancelDraft, finishSelection, select: setSelected, reset, } } export function createLineCommentController( props: LineCommentControllerWithSideProps, ): { note: ReturnType> annotations: Accessor>[]> renderAnnotation: ReturnType>["renderAnnotation"] renderHoverUtility: ReturnType onLineSelected: (range: SelectedLineRange | null) => void onLineSelectionEnd: (range: SelectedLineRange | null) => void onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void } export function createLineCommentController( props: LineCommentControllerProps, ): { note: ReturnType> annotations: Accessor[]> renderAnnotation: ReturnType>["renderAnnotation"] renderHoverUtility: ReturnType onLineSelected: (range: SelectedLineRange | null) => void onLineSelectionEnd: (range: SelectedLineRange | null) => void onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void } export function createLineCommentController( props: LineCommentControllerProps | LineCommentControllerWithSideProps, ) { const note = createLineCommentState(props.state) const annotations = "getSide" in props ? createLineCommentAnnotations({ comments: props.comments, getCommentId: (comment) => comment.id, getCommentSelection: (comment) => comment.selection, draftRange: note.commenting, draftKey: props.draftKey, getSide: props.getSide, }) : createLineCommentAnnotations({ comments: props.comments, getCommentId: (comment) => comment.id, getCommentSelection: (comment) => comment.selection, draftRange: note.commenting, draftKey: props.draftKey, }) const { renderAnnotation } = createManagedLineCommentAnnotationRenderer({ annotations, renderComment: (comment) => { const edit = () => note.openEditor(comment.id, comment.selection, comment.comment) const remove = () => { note.reset() props.onDelete?.(comment) } return { id: comment.id, get open() { return note.isOpen(comment.id) || note.isEditing(comment.id) }, comment: comment.comment, selection: formatSelectedLineLabel(comment.selection), get actions() { return props.renderCommentActions?.(comment, { edit, remove }) }, get editor() { return note.isEditing(comment.id) ? { get value() { return note.draft() }, selection: formatSelectedLineLabel(comment.selection), onInput: note.setDraft, onCancel: note.cancelDraft, onSubmit: (value: string) => { props.onUpdate?.({ id: comment.id, comment: value, selection: cloneSelectedLineRange(comment.selection), }) note.cancelDraft() }, submitLabel: props.editSubmitLabel, } : undefined }, onMouseEnter: () => note.hoverComment(comment.selection), onClick: () => { if (note.isEditing(comment.id)) return note.toggleComment(comment.id, comment.selection, { cancelDraft: props.cancelDraftOnCommentToggle }) }, } }, renderDraft: (range) => ({ get value() { return note.draft() }, selection: formatSelectedLineLabel(range), onInput: note.setDraft, onCancel: note.cancelDraft, onSubmit: (comment) => { props.onSubmit({ comment, selection: cloneSelectedLineRange(range) }) note.cancelDraft() }, onPopoverFocusOut: props.onDraftPopoverFocusOut, }), }) const renderHoverUtility = createLineCommentHoverRenderer({ label: props.label, getSelectedRange: () => { if (note.opened()) return null return props.getHoverSelectedRange?.() ?? note.selected() }, onOpenDraft: note.openDraft, }) const onLineSelected = (range: SelectedLineRange | null) => { if (!range) { note.select(null) note.cancelDraft() return } note.select(range) } const onLineSelectionEnd = (range: SelectedLineRange | null) => { if (!range) { if (props.clearSelectionOnSelectionEndNull) note.select(null) note.cancelDraft() return } note.finishSelection(range) } const onLineNumberSelectionEnd = (range: SelectedLineRange | null) => { if (!range) return note.openDraft(range) } return { note, annotations, renderAnnotation, renderHoverUtility, onLineSelected, onLineSelectionEnd, onLineNumberSelectionEnd, } } export function createLineCommentAnnotations( props: LineCommentAnnotationsWithSideProps, ): Accessor>[]> export function createLineCommentAnnotations( props: LineCommentAnnotationsProps, ): Accessor[]> export function createLineCommentAnnotations( props: LineCommentAnnotationsProps | LineCommentAnnotationsWithSideProps, ) { const line = (range: SelectedLineRange) => Math.max(range.start, range.end) if ("getSide" in props) { return createMemo>[]>(() => { const list = props.comments().map((comment) => { const range = props.getCommentSelection(comment) return { side: props.getSide(range), lineNumber: line(range), metadata: { kind: "comment", key: `comment:${props.getCommentId(comment)}`, comment, } satisfies LineCommentAnnotationMeta, } }) const range = props.draftRange() if (!range) return list return [ ...list, { side: props.getSide(range), lineNumber: line(range), metadata: { kind: "draft", key: `draft:${props.draftKey()}`, range, } satisfies LineCommentAnnotationMeta, }, ] }) } return createMemo[]>(() => { const list = props.comments().map((comment) => { const range = props.getCommentSelection(comment) const entry: LineCommentAnnotation = { lineNumber: line(range), metadata: { kind: "comment", key: `comment:${props.getCommentId(comment)}`, comment, }, } return entry }) const range = props.draftRange() if (!range) return list const draft: LineCommentAnnotation = { lineNumber: line(range), metadata: { kind: "draft", key: `draft:${props.draftKey()}`, range, }, } return [...list, draft] }) } export function createManagedLineCommentAnnotationRenderer(props: { annotations: Accessor[]> renderComment: (comment: T) => CommentProps renderDraft: (range: SelectedLineRange) => DraftProps }) { const renderer = createLineCommentAnnotationRenderer({ renderComment: props.renderComment, renderDraft: props.renderDraft, }) createEffect(() => { renderer.reconcile(props.annotations()) }) onCleanup(() => { renderer.cleanup() }) return { renderAnnotation: renderer.render, } } export function createLineCommentHoverRenderer(props: { label: string getSelectedRange: Accessor onOpenDraft: (range: SelectedLineRange) => void }) { return (getHoveredLine: () => HoverCommentLine | undefined) => createHoverCommentUtility({ label: props.label, getHoveredLine, onSelect: (hovered) => { const current = props.getSelectedRange() if (current && lineInSelectedRange(current, hovered.lineNumber, hovered.side)) { props.onOpenDraft(cloneSelectedLineRange(current)) return } const range: SelectedLineRange = { start: hovered.lineNumber, end: hovered.lineNumber, } if (hovered.side) range.side = hovered.side props.onOpenDraft(range) }, }) }