| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import { Extension } from "@tiptap/core"; |
| import { Plugin, PluginKey } from "@tiptap/pm/state"; |
| import { Decoration, DecorationSet } from "@tiptap/pm/view"; |
| import { |
| absolutePositionToRelativePosition, |
| relativePositionToAbsolutePosition, |
| ySyncPluginKey, |
| } from "@tiptap/y-tiptap"; |
| import * as Y from "yjs"; |
|
|
| export interface AgentFocusUser { |
| name: string; |
| color: string; |
| avatarUrl?: string; |
| } |
|
|
| export interface AgentFocusOptions { |
| |
| provider: any; |
| user: AgentFocusUser; |
| } |
|
|
| interface AwarenessFocus { |
| anchor: unknown; |
| head: unknown; |
| user: AgentFocusUser; |
| } |
|
|
| export const agentFocusPluginKey = new PluginKey("agent-focus"); |
|
|
| declare module "@tiptap/core" { |
| |
| interface Commands<ReturnType> { |
| agentFocus: { |
| |
| setAgentFocus: (args: { from: number; to: number }) => ReturnType; |
| |
| clearAgentFocus: () => ReturnType; |
| |
| updateAgentFocusUser: (user: AgentFocusUser) => ReturnType; |
| }; |
| } |
| } |
|
|
| export const AgentFocus = Extension.create<AgentFocusOptions>({ |
| name: "agentFocus", |
|
|
| addOptions() { |
| return { |
| provider: null, |
| user: { name: "Anonymous", color: "#5c7cfa" }, |
| }; |
| }, |
|
|
| onCreate() { |
| if (!this.options.provider) { |
| throw new Error('The "provider" option is required for AgentFocus'); |
| } |
| }, |
|
|
| addCommands() { |
| |
| |
| |
| |
| |
| |
| return { |
| setAgentFocus: |
| ({ from, to }) => |
| ({ state }) => { |
| const ystate = ySyncPluginKey.getState(state); |
| if (!ystate?.binding) return false; |
|
|
| const size = state.doc.content.size; |
| const a = Math.max(0, Math.min(from, size)); |
| const b = Math.max(0, Math.min(to, size)); |
| if (a === b) return false; |
| const [lo, hi] = a < b ? [a, b] : [b, a]; |
|
|
| const anchor = absolutePositionToRelativePosition( |
| lo, |
| ystate.type, |
| ystate.binding.mapping, |
| ); |
| const head = absolutePositionToRelativePosition( |
| hi, |
| ystate.type, |
| ystate.binding.mapping, |
| ); |
|
|
| this.options.provider.awareness.setLocalStateField("agentFocus", { |
| anchor: Y.relativePositionToJSON(anchor), |
| head: Y.relativePositionToJSON(head), |
| user: this.options.user, |
| }); |
| return true; |
| }, |
|
|
| clearAgentFocus: () => () => { |
| this.options.provider.awareness.setLocalStateField("agentFocus", null); |
| return true; |
| }, |
|
|
| updateAgentFocusUser: (user) => () => { |
| this.options.user = user; |
| |
| const current = this.options.provider.awareness.getLocalState() |
| ?.agentFocus as AwarenessFocus | null | undefined; |
| if (current?.anchor && current?.head) { |
| this.options.provider.awareness.setLocalStateField("agentFocus", { |
| ...current, |
| user, |
| }); |
| } |
| return true; |
| }, |
| }; |
| }, |
|
|
| addProseMirrorPlugins() { |
| const awareness = this.options.provider.awareness; |
|
|
| return [ |
| new Plugin({ |
| key: agentFocusPluginKey, |
| state: { |
| init: () => ({ stamp: 0 }), |
| apply(tr, prev) { |
| if (tr.getMeta(agentFocusPluginKey)) { |
| return { stamp: prev.stamp + 1 }; |
| } |
| return prev; |
| }, |
| }, |
| props: { |
| decorations(state) { |
| const ystate = ySyncPluginKey.getState(state); |
| if (!ystate?.binding) return null; |
|
|
| const decorations: Decoration[] = []; |
| const localClientId = awareness.clientID; |
|
|
| awareness.getStates().forEach((s: any, clientId: number) => { |
| const focus = s?.agentFocus as |
| | AwarenessFocus |
| | null |
| | undefined; |
| if (!focus || !focus.anchor || !focus.head) return; |
| const isSelf = clientId === localClientId; |
|
|
| let from: number | null = null; |
| let to: number | null = null; |
| try { |
| from = relativePositionToAbsolutePosition( |
| ystate.doc, |
| ystate.type, |
| Y.createRelativePositionFromJSON(focus.anchor), |
| ystate.binding.mapping, |
| ); |
| to = relativePositionToAbsolutePosition( |
| ystate.doc, |
| ystate.type, |
| Y.createRelativePositionFromJSON(focus.head), |
| ystate.binding.mapping, |
| ); |
| } catch { |
| return; |
| } |
|
|
| if (from == null || to == null || from === to) return; |
| const [lo, hi] = from < to ? [from, to] : [to, from]; |
|
|
| const color = focus.user?.color || "#5c7cfa"; |
| const name = focus.user?.name || ""; |
| |
| |
| |
| |
| const style = `background-color: ${color}70`; |
|
|
| decorations.push( |
| Decoration.inline( |
| lo, |
| hi, |
| { |
| class: `agent-focus${isSelf ? " agent-focus--self" : ""}`, |
| style, |
| "data-agent-focus-user": name, |
| "data-agent-focus-color": color, |
| } as any, |
| |
| |
| |
| |
| { inclusiveStart: false, inclusiveEnd: false } as any, |
| ), |
| ); |
|
|
| |
| |
| |
| |
| |
| |
| |
| decorations.push( |
| Decoration.widget( |
| hi, |
| () => { |
| const anchor = document.createElement("span"); |
| anchor.className = "agent-focus__anchor"; |
|
|
| const label = document.createElement("span"); |
| label.className = "agent-focus__label"; |
| label.setAttribute( |
| "style", |
| `background-color: ${color}`, |
| ); |
|
|
| const avatarUrl = focus.user?.avatarUrl; |
| if (avatarUrl) { |
| const avatar = document.createElement("img"); |
| avatar.src = avatarUrl; |
| avatar.className = "agent-focus__avatar"; |
| avatar.alt = ""; |
| label.appendChild(avatar); |
| } |
|
|
| const text = document.createElement("span"); |
| text.className = "agent-focus__text"; |
| text.textContent = `${name || "Someone"} agent`; |
| label.appendChild(text); |
|
|
| anchor.appendChild(label); |
| return anchor; |
| }, |
| { |
| |
| |
| |
| |
| |
| side: 1, |
| key: `agent-focus-label-${clientId}-${name}`, |
| } as any, |
| ), |
| ); |
| }); |
|
|
| if (decorations.length === 0) return null; |
| return DecorationSet.create(state.doc, decorations); |
| }, |
| }, |
| view(view) { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const handler = () => { |
| if (view.isDestroyed) return; |
| view.dispatch( |
| view.state.tr.setMeta(agentFocusPluginKey, { refresh: true }), |
| ); |
| }; |
| awareness.on("change", handler); |
| return { |
| destroy() { |
| awareness.off("change", handler); |
| }, |
| }; |
| }, |
| }), |
| ]; |
| }, |
| }); |
|
|