diff --git a/CHANGELOG.md b/CHANGELOG.md index 76138284f..b0d51176d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ ### Added +- Follow collaborators and their AI agents from the avatars in the toolbar: an avatar counts that person's agents, hovering lists what each is doing and on which page, and clicking follows. A frame in their color and a “Following …” bar show whom you follow; Escape, clicking, scrolling, zooming, or switching pages stops it. Your own avatar renames your agents and leaves the room. - See where the built-in AI chat is working: while it replies, a cursor whose outlined label shows a sparkle and a callsign such as *Fern* marks the layers it edits. In a shared room, collaborators see each other's agents in the color of the person running them. - Hide, lock, and constrain layers in design JSX with `visible={false}`, `locked`, and `constraints={{ horizontal, vertical }}`, set italic text with `italic`, and describe strokes fully with `strokes`, `strokeWeights`, `strokeCap`, `strokeJoin`, and the node-level `dashPattern`. JSX export now writes these together with stacked, gradient, and image fills, every effect, absolutely positioned children, size limits, vertical text alignment, masks, and variable bindings, so rendering exported JSX reproduces them and `diff_jsx` reports changes to them. - Jump between pages from the command palette: it lists the pages you visited recently in the tab, **Go to page…** lists every page, and typing a page name finds it. @@ -35,6 +36,7 @@ ### Changed +- Keep the Share button labeled Share while you are in a room, instead of turning it into a Connected status; a green dot on your avatar shows the room is live. - Show Flatten, Outline text, and Outline stroke in the canvas context menu without icons, like every other item there. - Keep an AI chat working on the page where it started when you switch to another page, instead of sending its next edits to whichever page is on screen. When the AI switches pages itself, your view follows. - `openpencil://` and web `?node=` links select the layer on another page when the current page has none, switching to that page. diff --git a/packages/core/src/editor/viewport.ts b/packages/core/src/editor/viewport.ts index 193deacd7..c99ab8496 100644 --- a/packages/core/src/editor/viewport.ts +++ b/packages/core/src/editor/viewport.ts @@ -74,6 +74,22 @@ export function createViewportActions(ctx: EditorContext) { emitViewportChanged(previous) } + /** Put the world point (x, y) at the center of the viewport, at `zoom` (the current one by default). */ + function centerOn(x: number, y: number, zoom = ctx.state.zoom) { + const previous = currentViewport() + const { width, height } = ctx.getViewportSize() + const nextZoom = Math.max(0.02, Math.min(256, zoom)) + const panX = width / 2 - x * nextZoom + const panY = height / 2 - y * nextZoom + // Remote cursors are finite but unbounded; a point that overflows leaves the view alone. + if (!Number.isFinite(panX) || !Number.isFinite(panY)) return + ctx.state.zoom = nextZoom + ctx.state.panX = panX + ctx.state.panY = panY + ctx.requestRepaint() + emitViewportChanged(previous) + } + function zoomToFit() { const nodes = ctx.graph.getChildren(ctx.state.currentPageId) if (nodes.length === 0) return @@ -118,6 +134,7 @@ export function createViewportActions(ctx: EditorContext) { applyZoom, pan, zoomToBounds, + centerOn, zoomToFit, zoomTo100, zoomToLevel, diff --git a/packages/core/tests/editor/viewport.test.ts b/packages/core/tests/editor/viewport.test.ts index 74e3b2dd9..d1b9d7d64 100644 --- a/packages/core/tests/editor/viewport.test.ts +++ b/packages/core/tests/editor/viewport.test.ts @@ -24,3 +24,19 @@ describe('zoomToLevel', () => { expect(center(editor)).toEqual(before) }) }) + +describe('centerOn', () => { + test('puts the point at the center at the given zoom', () => { + const editor = createEditor({ getViewportSize: () => VIEWPORT }) + editor.centerOn(300, 200, 2) + expect(editor.state.zoom).toBe(2) + expect(center(editor)).toEqual({ x: 300, y: 200 }) + }) + + test('leaves the view alone for a point too far away to represent', () => { + const editor = createEditor({ getViewportSize: () => VIEWPORT }) + const before = { ...center(editor), zoom: editor.state.zoom } + editor.centerOn(1e308, 0, 256) + expect({ ...center(editor), zoom: editor.state.zoom }).toEqual(before) + }) +}) diff --git a/packages/docs/programmable/collaboration.md b/packages/docs/programmable/collaboration.md index e6ae842ae..39a17f57e 100644 --- a/packages/docs/programmable/collaboration.md +++ b/packages/docs/programmable/collaboration.md @@ -24,7 +24,9 @@ Anyone with the link can join. The room stays active as long as at least one par ## Follow Mode -Click a collaborator's avatar in the top bar to follow their viewport. Your canvas pans and zooms to match their view. Click again to stop following. +Click a collaborator's avatar in the top bar to follow their viewport. Your canvas pans and zooms to match their view, and a frame in their color with a “Following …” bar shows whom you follow. Click the avatar again, press Esc, or click, scroll, zoom, or switch pages yourself to stop. + +An avatar counts the agents that person runs. Hover over it to see each agent, what it is doing, and on which page, and click **Follow** next to an agent to keep the page and layers it is editing in view; following continues between its replies and stops when it leaves. The button after the avatars lists everyone in the room with their agents, and works from the keyboard. Your own avatar lists your agents — click one to rename it — and has **Leave room**. ## How It Works diff --git a/packages/vue/src/i18n/locales/de/collaboration.json b/packages/vue/src/i18n/locales/de/collaboration.json index 50d0d72da..a430d9004 100644 --- a/packages/vue/src/i18n/locales/de/collaboration.json +++ b/packages/vue/src/i18n/locales/de/collaboration.json @@ -1,7 +1,5 @@ { "inThisRoom": "In diesem Raum", - "followingPeerStop": "Folge {name} (zum Stoppen klicken)", - "clickToFollowPeer": "Klicken, um {name} zu folgen", "yourName": "Dein Name", "enterYourName": "Deinen Namen eingeben", "shareThisFile": "Diese Datei teilen", @@ -13,5 +11,17 @@ "pasteRoomLinkOrId": "Raumlink oder ID einfügen", "connected": "Verbunden", "disconnect": "Verbindung trennen", - "share": "Teilen" + "share": "Teilen", + "follow": "{name} folgen", + "stopFollowing": "{name} nicht mehr folgen", + "followingPerson": "Du folgst {name}", + "followingAgent": "Du folgst {agent} ({owner})", + "stopFollowingShort": "Nicht mehr folgen", + "leaveRoom": "Raum verlassen", + "morePeople": "{count} weitere", + "renameAgent": "{name} umbenennen", + "agentName": "Agentenname", + "agentThinking": "Denkt nach", + "agentEditing": "Bearbeitet", + "agentIdle": "Inaktiv" } diff --git a/packages/vue/src/i18n/locales/es/collaboration.json b/packages/vue/src/i18n/locales/es/collaboration.json index 725f3b8ab..d691fc71e 100644 --- a/packages/vue/src/i18n/locales/es/collaboration.json +++ b/packages/vue/src/i18n/locales/es/collaboration.json @@ -1,7 +1,5 @@ { "inThisRoom": "En esta sala", - "followingPeerStop": "Siguiendo a {name} (haz clic para detener)", - "clickToFollowPeer": "Haz clic para seguir a {name}", "yourName": "Tu nombre", "enterYourName": "Introduce tu nombre", "shareThisFile": "Compartir este archivo", @@ -13,5 +11,17 @@ "pasteRoomLinkOrId": "Pega el enlace o ID de la sala", "connected": "Conectado", "disconnect": "Desconectar", - "share": "Compartir" + "share": "Compartir", + "follow": "Seguir a {name}", + "stopFollowing": "Dejar de seguir a {name}", + "followingPerson": "Siguiendo a {name}", + "followingAgent": "Siguiendo a {agent} ({owner})", + "stopFollowingShort": "Dejar de seguir", + "leaveRoom": "Salir de la sala", + "morePeople": "{count} más", + "renameAgent": "Renombrar {name}", + "agentName": "Nombre del agente", + "agentThinking": "Pensando", + "agentEditing": "Editando", + "agentIdle": "Inactivo" } diff --git a/packages/vue/src/i18n/locales/fr/collaboration.json b/packages/vue/src/i18n/locales/fr/collaboration.json index 3d8c5f555..70e1c887c 100644 --- a/packages/vue/src/i18n/locales/fr/collaboration.json +++ b/packages/vue/src/i18n/locales/fr/collaboration.json @@ -1,7 +1,5 @@ { "inThisRoom": "Dans cette salle", - "followingPeerStop": "Suit {name} (cliquer pour arrêter)", - "clickToFollowPeer": "Cliquer pour suivre {name}", "yourName": "Votre nom", "enterYourName": "Entrez votre nom", "shareThisFile": "Partager ce fichier", @@ -13,5 +11,17 @@ "pasteRoomLinkOrId": "Collez le lien ou l’ID de la salle", "connected": "Connecté", "disconnect": "Déconnecter", - "share": "Partager" + "share": "Partager", + "follow": "Suivre {name}", + "stopFollowing": "Ne plus suivre {name}", + "followingPerson": "Vous suivez {name}", + "followingAgent": "Vous suivez {agent} ({owner})", + "stopFollowingShort": "Ne plus suivre", + "leaveRoom": "Quitter la salle", + "morePeople": "{count} de plus", + "renameAgent": "Renommer {name}", + "agentName": "Nom de l’agent", + "agentThinking": "Réfléchit", + "agentEditing": "Modifie", + "agentIdle": "Inactif" } diff --git a/packages/vue/src/i18n/locales/it/collaboration.json b/packages/vue/src/i18n/locales/it/collaboration.json index 26c5a3f8d..8e39c32fb 100644 --- a/packages/vue/src/i18n/locales/it/collaboration.json +++ b/packages/vue/src/i18n/locales/it/collaboration.json @@ -1,7 +1,5 @@ { "inThisRoom": "In questa stanza", - "followingPeerStop": "Segui {name} (clic per interrompere)", - "clickToFollowPeer": "Fai clic per seguire {name}", "yourName": "Il tuo nome", "enterYourName": "Inserisci il tuo nome", "shareThisFile": "Condividi questo file", @@ -13,5 +11,17 @@ "pasteRoomLinkOrId": "Incolla il link o l'ID della stanza", "connected": "Connesso", "disconnect": "Disconnetti", - "share": "Condividi" + "share": "Condividi", + "follow": "Segui {name}", + "stopFollowing": "Smetti di seguire {name}", + "followingPerson": "Stai seguendo {name}", + "followingAgent": "Stai seguendo {agent} ({owner})", + "stopFollowingShort": "Smetti di seguire", + "leaveRoom": "Esci dalla stanza", + "morePeople": "Altri {count}", + "renameAgent": "Rinomina {name}", + "agentName": "Nome dell’agente", + "agentThinking": "Sta pensando", + "agentEditing": "Sta modificando", + "agentIdle": "Inattivo" } diff --git a/packages/vue/src/i18n/locales/ja/collaboration.json b/packages/vue/src/i18n/locales/ja/collaboration.json index 41193fe00..39f64b718 100644 --- a/packages/vue/src/i18n/locales/ja/collaboration.json +++ b/packages/vue/src/i18n/locales/ja/collaboration.json @@ -1,7 +1,5 @@ { "inThisRoom": "このルーム内", - "followingPeerStop": "{name} をフォロー中 (クリックして停止)", - "clickToFollowPeer": "クリックして {name} をフォロー", "yourName": "お名前", "enterYourName": "名前を入力してください", "shareThisFile": "このファイルを共有", @@ -13,5 +11,17 @@ "pasteRoomLinkOrId": "ルームのリンクまたはIDを貼り付け", "connected": "接続済み", "disconnect": "切断", - "share": "共有" + "share": "共有", + "follow": "{name}をフォロー", + "stopFollowing": "{name}のフォローを解除", + "followingPerson": "{name}をフォロー中", + "followingAgent": "{agent}({owner})をフォロー中", + "stopFollowingShort": "フォローを解除", + "leaveRoom": "ルームを退出", + "morePeople": "他{count}人", + "renameAgent": "{name}の名前を変更", + "agentName": "エージェント名", + "agentThinking": "考え中", + "agentEditing": "編集中", + "agentIdle": "待機中" } diff --git a/packages/vue/src/i18n/locales/pl/collaboration.json b/packages/vue/src/i18n/locales/pl/collaboration.json index 54ef02dd6..cd3c5b7f2 100644 --- a/packages/vue/src/i18n/locales/pl/collaboration.json +++ b/packages/vue/src/i18n/locales/pl/collaboration.json @@ -1,7 +1,5 @@ { "inThisRoom": "W tym pokoju", - "followingPeerStop": "Obserwujesz {name} (kliknij, aby zatrzymać)", - "clickToFollowPeer": "Kliknij, aby obserwować {name}", "yourName": "Twoje imię", "enterYourName": "Wpisz swoje imię", "shareThisFile": "Udostępnij ten plik", @@ -13,5 +11,17 @@ "pasteRoomLinkOrId": "Wklej link lub ID pokoju", "connected": "Połączono", "disconnect": "Rozłącz", - "share": "Udostępnij" + "share": "Udostępnij", + "follow": "Obserwuj {name}", + "stopFollowing": "Przestań obserwować {name}", + "followingPerson": "Obserwujesz: {name}", + "followingAgent": "Obserwujesz: {agent} ({owner})", + "stopFollowingShort": "Przestań obserwować", + "leaveRoom": "Opuść pokój", + "morePeople": "{count} więcej", + "renameAgent": "Zmień nazwę {name}", + "agentName": "Nazwa agenta", + "agentThinking": "Myśli", + "agentEditing": "Edytuje", + "agentIdle": "Bezczynny" } diff --git a/packages/vue/src/i18n/locales/ru/collaboration.json b/packages/vue/src/i18n/locales/ru/collaboration.json index 493840053..37cd2ccda 100644 --- a/packages/vue/src/i18n/locales/ru/collaboration.json +++ b/packages/vue/src/i18n/locales/ru/collaboration.json @@ -1,7 +1,5 @@ { "inThisRoom": "В этой комнате", - "followingPeerStop": "Следите за {name} (нажмите, чтобы остановить)", - "clickToFollowPeer": "Нажмите, чтобы следить за {name}", "yourName": "Ваше имя", "enterYourName": "Введите ваше имя", "shareThisFile": "Поделиться файлом", @@ -13,5 +11,17 @@ "pasteRoomLinkOrId": "Вставьте ссылку на комнату или ID", "connected": "Подключено", "disconnect": "Отключиться", - "share": "Поделиться" + "share": "Поделиться", + "follow": "Следовать за {name}", + "stopFollowing": "Перестать следовать за {name}", + "followingPerson": "Вы следуете за {name}", + "followingAgent": "Вы следуете за {agent} ({owner})", + "stopFollowingShort": "Перестать следовать", + "leaveRoom": "Покинуть комнату", + "morePeople": "Ещё {count}", + "renameAgent": "Переименовать {name}", + "agentName": "Имя агента", + "agentThinking": "Думает", + "agentEditing": "Редактирует", + "agentIdle": "Неактивен" } diff --git a/packages/vue/src/i18n/locales/zh-cn/collaboration.json b/packages/vue/src/i18n/locales/zh-cn/collaboration.json index 0e1ab9ed8..563866a54 100644 --- a/packages/vue/src/i18n/locales/zh-cn/collaboration.json +++ b/packages/vue/src/i18n/locales/zh-cn/collaboration.json @@ -1,7 +1,5 @@ { "inThisRoom": "此房间中", - "followingPeerStop": "正在跟随 {name}(点击停止)", - "clickToFollowPeer": "点击跟随 {name}", "yourName": "你的名字", "enterYourName": "请输入你的名字", "shareThisFile": "分享此文件", @@ -13,5 +11,17 @@ "pasteRoomLinkOrId": "粘贴房间链接或 ID", "connected": "已连接", "disconnect": "断开连接", - "share": "分享" + "share": "分享", + "follow": "跟随 {name}", + "stopFollowing": "停止跟随 {name}", + "followingPerson": "正在跟随 {name}", + "followingAgent": "正在跟随 {agent}({owner})", + "stopFollowingShort": "停止跟随", + "leaveRoom": "离开房间", + "morePeople": "还有 {count} 人", + "renameAgent": "重命名 {name}", + "agentName": "智能体名称", + "agentThinking": "思考中", + "agentEditing": "编辑中", + "agentIdle": "空闲" } diff --git a/packages/vue/src/i18n/messages/collaboration.ts b/packages/vue/src/i18n/messages/collaboration.ts index aa9bf40ef..788b638dc 100644 --- a/packages/vue/src/i18n/messages/collaboration.ts +++ b/packages/vue/src/i18n/messages/collaboration.ts @@ -4,8 +4,6 @@ import { i18n } from '#vue/i18n/create' export const collaborationMessageDefaults = { inThisRoom: 'In this room', - followingPeerStop: params('Following {name} (click to stop)'), - clickToFollowPeer: params('Click to follow {name}'), yourName: 'Your name', enterYourName: 'Enter your name', shareThisFile: 'Share this file', @@ -17,7 +15,19 @@ export const collaborationMessageDefaults = { pasteRoomLinkOrId: 'Paste room link or ID', connected: 'Connected', disconnect: 'Disconnect', - share: 'Share' + share: 'Share', + follow: params('Follow {name}'), + stopFollowing: params('Stop following {name}'), + followingPerson: params('Following {name}'), + followingAgent: params('Following {agent} ({owner})'), + stopFollowingShort: 'Stop following', + leaveRoom: 'Leave room', + morePeople: params('{count} more'), + renameAgent: params('Rename {name}'), + agentName: 'Agent name', + agentThinking: 'Thinking', + agentEditing: 'Editing', + agentIdle: 'Idle' } as const export const collaborationMessages = i18n('collaboration', collaborationMessageDefaults) diff --git a/src/app/collab/awareness.ts b/src/app/collab/awareness.ts index 53ea24f02..a6f69c948 100644 --- a/src/app/collab/awareness.ts +++ b/src/app/collab/awareness.ts @@ -1,23 +1,10 @@ -import { ref } from 'vue' -import type * as awarenessProtocol from 'y-protocols/awareness' - import { randomIndex } from '@open-pencil/core/random' -import type { EditorStore } from '@/app/editor/active-store' import { parsePeer } from '@/app/presence/schema' import { ROOM_ID_CHARS, ROOM_ID_LENGTH } from '@/constants' import type { RemotePeer } from './types' -type Awareness = awarenessProtocol.Awareness - -type CursorState = { - x: number - y: number - pageId: string - zoom?: number -} - /** People in the room other than us, validated: awareness comes from other browsers. */ export function buildRemotePeers( states: Map>, @@ -28,46 +15,6 @@ export function buildRemotePeers( ) } -export function createFollowActions( - getStore: () => EditorStore, - getAwareness: () => Awareness | null -) { - const followingPeer = ref(null) - - function followPeer(clientId: number | null) { - followingPeer.value = clientId - } - - function resetFollow() { - followingPeer.value = null - } - - function tickFollow() { - const store = getStore() - const awareness = getAwareness() - if (!followingPeer.value || !awareness) return - const peerState = awareness.getStates().get(followingPeer.value) - if (!peerState?.cursor) { - followingPeer.value = null - return - } - const cursor = peerState.cursor as CursorState - if (cursor.pageId !== store.state.currentPageId) { - void store.switchPage(cursor.pageId) - } - const canvas = document.querySelector('canvas') - if (!canvas) return - if (cursor.zoom) store.state.zoom = cursor.zoom - const cw = canvas.width / devicePixelRatio - const ch = canvas.height / devicePixelRatio - store.state.panX = cw / 2 - cursor.x * store.state.zoom - store.state.panY = ch / 2 - cursor.y * store.state.zoom - store.requestRender() - } - - return { followingPeer, followPeer, resetFollow, tickFollow } -} - export function generateRoomId(): string { let result = '' for (let i = 0; i < ROOM_ID_LENGTH; i++) { diff --git a/src/app/collab/session.ts b/src/app/collab/session.ts index 3675f5c22..79661359d 100644 --- a/src/app/collab/session.ts +++ b/src/app/collab/session.ts @@ -1,4 +1,4 @@ -import type { Ref } from 'vue' +import { shallowRef, type Ref, type ShallowRef } from 'vue' import { IndexeddbPersistence } from 'y-indexeddb' import * as awarenessProtocol from 'y-protocols/awareness' import type { Awareness } from 'y-protocols/awareness' @@ -22,7 +22,8 @@ export type CollabRuntime = { yimages: Y.Map | null room: CollabRoomTransport | null persistence: IndexeddbPersistence | null - connectedStore: EditorStore | null + /** Reactive, so what depends on the room's document follows connects and disconnects. */ + connectedStore: ShallowRef suppressGraphSync: boolean suppressYjsEvents: boolean unbindGraphEvents: (() => void) | null @@ -37,7 +38,6 @@ type ConnectCollabSessionOptions = { store: EditorStore disconnect: () => void updatePeersList: () => void - tickFollow: () => void broadcastAwareness: () => void applyYjsToGraph: (events: Y.YEvent>[]) => void syncNodeToYjs: (nodeId: string) => void @@ -48,11 +48,9 @@ type CollabConnectionActionsOptions = { state: Ref getStore: () => EditorStore updatePeersList: () => void - tickFollow: () => void broadcastAwareness: () => void applyYjsToGraph: (events: Y.YEvent>[]) => void syncNodeToYjs: (nodeId: string) => void - resetFollow: () => void } type CollabSessionResources = { @@ -64,7 +62,6 @@ type CollabSessionResources = { unbindGraphEvents: (() => void) | null stopZoomWatch: (() => void) | null stopAgentSync: (() => void) | null - resetFollow: () => void } export function createCollabRuntime(): CollabRuntime { @@ -75,7 +72,7 @@ export function createCollabRuntime(): CollabRuntime { yimages: null, room: null, persistence: null, - connectedStore: null, + connectedStore: shallowRef(null), suppressGraphSync: false, suppressYjsEvents: false, unbindGraphEvents: null, @@ -99,11 +96,9 @@ export function createCollabConnectionActions({ state, getStore, updatePeersList, - tickFollow, broadcastAwareness, applyYjsToGraph, - syncNodeToYjs, - resetFollow + syncNodeToYjs }: CollabConnectionActionsOptions) { function connect(roomId: string) { connectCollabSession({ @@ -113,7 +108,6 @@ export function createCollabConnectionActions({ store: getStore(), disconnect, updatePeersList, - tickFollow, broadcastAwareness, applyYjsToGraph, syncNodeToYjs @@ -121,7 +115,7 @@ export function createCollabConnectionActions({ } function disconnect() { - const store = runtime.connectedStore ?? getStore() + const store = runtime.connectedStore.value ?? getStore() disposeCollabSessionResources({ store, room: runtime.room, @@ -130,8 +124,7 @@ export function createCollabConnectionActions({ ydoc: runtime.ydoc, unbindGraphEvents: runtime.unbindGraphEvents, stopZoomWatch: runtime.stopZoomWatch, - stopAgentSync: runtime.stopAgentSync, - resetFollow + stopAgentSync: runtime.stopAgentSync }) resetCollabRuntime(runtime) resetCollabConnectionState(state) @@ -160,14 +153,13 @@ export function connectCollabSession({ store, disconnect, updatePeersList, - tickFollow, broadcastAwareness, applyYjsToGraph, syncNodeToYjs }: ConnectCollabSessionOptions) { if (runtime.room) disconnect() - runtime.connectedStore = store + runtime.connectedStore.value = store state.value.roomId = roomId runtime.ydoc = new Y.Doc() runtime.awareness = new awarenessProtocol.Awareness(runtime.ydoc) @@ -175,10 +167,8 @@ export function connectCollabSession({ runtime.yimages = runtime.ydoc.getMap('images') runtime.persistence = new IndexeddbPersistence(`op-room-${roomId}`, runtime.ydoc) - runtime.awareness.on('change', () => { - updatePeersList() - tickFollow() - }) + // Peer updates also move the view of anyone following them. + runtime.awareness.on('change', updatePeersList) registerYjsObservers({ store, @@ -229,7 +219,7 @@ export function resetCollabRuntime(runtime: CollabRuntime) { runtime.ydoc = null runtime.ynodes = null runtime.yimages = null - runtime.connectedStore = null + runtime.connectedStore.value = null } export function resetCollabConnectionState(state: Ref) { @@ -248,6 +238,5 @@ export function disposeCollabSessionResources(resources: CollabSessionResources) } resources.ydoc?.destroy() resources.stopAgentSync?.() - resources.resetFollow() setPeers(resources.store, []) } diff --git a/src/app/collab/types.ts b/src/app/collab/types.ts index 7bd5a2d63..da716bdce 100644 --- a/src/app/collab/types.ts +++ b/src/app/collab/types.ts @@ -1,12 +1,12 @@ import type { Color } from '@open-pencil/scene-graph/primitives' -import type { AgentPresence, PresencePoint } from '@/app/presence/types' +import type { AgentPresence, PersonPoint } from '@/app/presence/types' export interface RemotePeer { clientId: number name: string color: Color - cursor?: PresencePoint + cursor?: PersonPoint selection?: string[] /** Agents this person runs, as they publish them. */ agents: AgentPresence[] diff --git a/src/app/collab/use.ts b/src/app/collab/use.ts index ce92dadc0..39ed8846c 100644 --- a/src/app/collab/use.ts +++ b/src/app/collab/use.ts @@ -1,7 +1,7 @@ import { tryOnScopeDispose, useLocalStorage } from '@vueuse/core' import { computed, ref } from 'vue' -import { createFollowActions, generateRoomId } from '@/app/collab/awareness' +import { generateRoomId } from '@/app/collab/awareness' import { createLocalAwarenessActions } from '@/app/collab/local-awareness' import { createCollabConnectionActions, @@ -11,6 +11,8 @@ import { import { DEFAULT_COLLAB_STATE, type CollabState, type RemotePeer } from '@/app/collab/types' import { createYjsGraphSync } from '@/app/collab/yjs-sync' import type { EditorStore } from '@/app/editor/active-store' +import { follow, presenceOf } from '@/app/presence/registry' +import type { FollowTarget } from '@/app/presence/types' export { COLLAB_KEY, useCollabInjected } from '@/app/collab/context' export { DEFAULT_COLLAB_STATE } @@ -23,11 +25,11 @@ export function useCollab(storeOrGetter: EditorStore | (() => EditorStore)) { const state = ref(createInitialCollabState(storedName.value)) const runtime = createCollabRuntime() const remotePeers = computed(() => state.value.peers) - const getActiveStore = () => runtime.connectedStore ?? getStore() + const getActiveStore = () => runtime.connectedStore.value ?? getStore() - const { followingPeer, followPeer, resetFollow, tickFollow } = createFollowActions( - getActiveStore, - () => runtime.awareness + const following = computed(() => presenceOf(getActiveStore()).following.value) + const followingPeer = computed(() => + following.value?.kind === 'person' ? following.value.clientId : null ) const { broadcastAwareness, updateCursor, updateSelection, updatePeersList, setLocalName } = createLocalAwarenessActions({ @@ -51,11 +53,9 @@ export function useCollab(storeOrGetter: EditorStore | (() => EditorStore)) { state, getStore, updatePeersList, - tickFollow, broadcastAwareness, applyYjsToGraph, - syncNodeToYjs, - resetFollow + syncNodeToYjs }) function shareCurrentDoc(): string { @@ -77,7 +77,9 @@ export function useCollab(storeOrGetter: EditorStore | (() => EditorStore)) { updateCursor, updateSelection, setLocalName, - followPeer, - tickFollow + following, + follow: (target: FollowTarget | null) => follow(getActiveStore(), target), + followPeer: (clientId: number | null) => + follow(getActiveStore(), clientId === null ? null : { kind: 'person', clientId }) } } diff --git a/src/app/presence/follow-view.ts b/src/app/presence/follow-view.ts new file mode 100644 index 000000000..198d9cd57 --- /dev/null +++ b/src/app/presence/follow-view.ts @@ -0,0 +1,26 @@ +import { useEventListener } from '@vueuse/core' +import { computed, type MaybeRefOrGetter } from 'vue' + +import { useActiveEditorStoreRef } from '@/app/editor/active-store' + +import { follow, followedLabel, presenceOf } from './registry' + +/** + * Who the active document's view follows, for a canvas pane's frame and banner. Your own + * input on the canvas — a click, scrolling or zooming — stops following; the frame also + * stops it on Escape. + */ +export function useFollowView(area: MaybeRefOrGetter) { + // Presence belongs to the store object itself, not the forwarding proxy components get. + const storeRef = useActiveEditorStoreRef() + const label = computed(() => (storeRef.value ? followedLabel(storeRef.value) : null)) + + const stop = () => { + const store = storeRef.value + if (store && presenceOf(store).following.value) follow(store, null) + } + + useEventListener(area, ['pointerdown', 'wheel'], stop, { capture: true, passive: true }) + + return { label, stop } +} diff --git a/src/app/presence/registry.ts b/src/app/presence/registry.ts index f086d0e4f..ff67f752d 100644 --- a/src/app/presence/registry.ts +++ b/src/app/presence/registry.ts @@ -9,7 +9,8 @@ import type { RemotePeer } from '@/app/collab/types' import type { EditorStore } from '@/app/editor/active-store' import { pickCallsign } from './callsigns' -import type { AgentKind, AgentPresence } from './types' +import { MAX_NAME_LENGTH } from './schema' +import type { AgentKind, AgentPresence, FollowTarget, PersonPoint } from './types' /** Agents' color outside a room, where there is no collaborator color to inherit. */ const SOLO_AGENT_COLOR: Color = { ...AI_ACTIVE_COLOR, a: 1 } @@ -21,6 +22,14 @@ interface Presence { peers: ShallowRef /** The local collaborator's color while in a room. */ ownerColor: ShallowRef + /** Who the viewport follows, if anyone. */ + following: ShallowRef + /** The page following put the view on; landing anywhere else means you left. */ + followPage: string | null + /** The page a follow switch is on its way to, so cursor updates do not restart it. */ + switching: string | null + /** True while following centers the view; any other viewport change is yours. */ + moving: boolean } const presences = new WeakMap() @@ -31,10 +40,22 @@ export function presenceOf(store: EditorStore): Presence { const presence: Presence = { agents: shallowRef([]), peers: shallowRef([]), - ownerColor: shallowRef(null) + ownerColor: shallowRef(null), + following: shallowRef(null), + followPage: null, + switching: null, + moving: false } presences.set(store, presence) - store.onEditorEvent('page:changed', () => refreshCursors(store)) + store.onEditorEvent('page:changed', () => { + stopIfLeftFollowedPage(store) + refreshCursors(store) + }) + // Zooming or fitting the view yourself, by any shortcut or menu, ends following. A page + // switch restores that page's viewport without this event, so only centering is ours. + store.onEditorEvent('viewport:changed', () => { + if (presence.following.value && !presence.moving) stopFollowing(store) + }) return presence } @@ -68,6 +89,125 @@ export function refreshCursors(store: EditorStore): void { ) ] store.requestRepaint() + keepFollowing(store) +} + +/** + * Where the followed person or agent is: null when they left, idle while an agent rests or a + * person has not pointed anywhere yet. + */ +function followedPoint(store: EditorStore, target: FollowTarget): PersonPoint | 'idle' | null { + const { agents, peers } = presenceOf(store) + if (target.kind === 'person') { + const peer = peers.value.find((entry) => entry.clientId === target.clientId) + return peer ? (peer.cursor ?? 'idle') : null + } + const agent = [...agents.value, ...peers.value.flatMap((peer) => peer.agents)].find( + (entry) => entry.id === target.agentId + ) + if (!agent) return null + return agent.status === 'idle' ? 'idle' : (agent.cursor ?? 'idle') +} + +/** Move the view to the followed person or agent; stop when they are gone. */ +function keepFollowing(store: EditorStore): void { + const presence = presenceOf(store) + const target = presence.following.value + if (!target) return + const point = followedPoint(store, target) + if (point === 'idle') return + if (!point) { + presence.following.value = null + return + } + // A peer's cursor can name a page this document does not have; wait as for a resting agent. + if (store.graph.getNode(point.pageId)?.type !== 'CANVAS') return + presence.followPage = point.pageId + if (point.pageId !== store.state.currentPageId) { + // Each switch cancels the one before it, so frequent cursor updates must not restart one + // already heading to this page; when it lands, follow wherever they went meanwhile. + if (presence.switching === point.pageId) return + const pageId = point.pageId + presence.switching = pageId + void store.switchPage(pageId).finally(() => { + if (presence.switching === pageId) presence.switching = null + if (store.state.currentPageId === pageId) keepFollowing(store) + }) + return + } + presence.moving = true + try { + store.centerOn(point.x, point.y, point.zoom) + } finally { + presence.moving = false + } +} + +/** A page change following did not make is yours, so it ends following, even of a resting agent. */ +function stopIfLeftFollowedPage(store: EditorStore): void { + const presence = presenceOf(store) + if (presence.following.value && store.state.currentPageId !== presence.followPage) { + presence.following.value = null + } +} + +/** Who the view follows, for showing it: their name, an agent's owner, and the color. */ +export interface FollowedLabel { + kind: FollowTarget['kind'] + name: string + /** For an agent, the person who runs it; undefined for our own agents. */ + owner?: string + color: Color +} + +export function followedLabel(store: EditorStore): FollowedLabel | null { + const { agents, peers, ownerColor, following } = presenceOf(store) + const target = following.value + if (!target) return null + if (target.kind === 'person') { + const peer = peers.value.find((entry) => entry.clientId === target.clientId) + return peer ? { kind: 'person', name: peer.name, color: peer.color } : null + } + const own = agents.value.find((agent) => agent.id === target.agentId) + if (own) return { kind: 'agent', name: own.name, color: ownerColor.value ?? SOLO_AGENT_COLOR } + for (const peer of peers.value) { + const agent = peer.agents.find((entry) => entry.id === target.agentId) + if (agent) return { kind: 'agent', name: agent.name, owner: peer.name, color: peer.color } + } + return null +} + +/** Follow a person or an agent, or stop following with null. */ +/** Stop following; a follow switch still loading is overtaken, so it does not land later. */ +function stopFollowing(store: EditorStore): void { + const presence = presenceOf(store) + presence.following.value = null + if (presence.switching) { + presence.switching = null + void store.switchPage(store.state.currentPageId) + } +} + +export function follow(store: EditorStore, target: FollowTarget | null): void { + if (!target) { + stopFollowing(store) + return + } + const presence = presenceOf(store) + presence.following.value = target + presence.followPage = store.state.currentPageId + keepFollowing(store) +} + +/** Rename one of our agents; the new name reaches the room with our next awareness update. */ +export function renameAgent(store: EditorStore, agentId: string, name: string): void { + const trimmed = name.trim().slice(0, MAX_NAME_LENGTH) + const presence = presenceOf(store) + if (!trimmed) return + presence.agents.value = presence.agents.value.map((agent) => + agent.id === agentId ? { ...agent, name: trimmed } : agent + ) + refreshCursors(store) } export function setPeers(store: EditorStore, peers: readonly RemotePeer[]): void { @@ -102,6 +242,7 @@ export function addAgent(store: EditorStore, kind: AgentKind, model?: string): A status: 'idle' } presence.agents.value = [...presence.agents.value, agent] + const current = () => presence.agents.value.find((entry) => entry.id === agent.id) const replace = (next: AgentPresence | null) => { const agents = presence.agents.value presence.agents.value = next @@ -111,10 +252,13 @@ export function addAgent(store: EditorStore, kind: AgentKind, model?: string): A } return { id: agent.id, - name: agent.name, + // The owner may rename the agent. + get name() { + return current()?.name ?? agent.name + }, update: (patch) => { - const current = presence.agents.value.find((entry) => entry.id === agent.id) - if (current) replace({ ...current, ...patch }) + const entry = current() + if (entry) replace({ ...entry, ...patch }) }, remove: () => replace(null) } diff --git a/src/app/presence/schema.ts b/src/app/presence/schema.ts index 02a285a02..4690f401a 100644 --- a/src/app/presence/schema.ts +++ b/src/app/presence/schema.ts @@ -8,25 +8,29 @@ import { AGENT_KINDS, AGENT_STATUSES } from './types' /** Bounds on what a peer can publish, so a broken or hostile peer cannot flood rendering. */ export const MAX_AGENTS_PER_PEER = 16 const MAX_SELECTION = 256 -const MAX_NAME = 40 +export const MAX_NAME_LENGTH = 40 const MAX_ID = 64 const finite = v.pipe(v.number(), v.finite()) const unit = v.pipe(finite, v.minValue(0), v.maxValue(1)) const id = v.pipe(v.string(), v.maxLength(MAX_ID)) -const name = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(MAX_NAME)) +const name = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(MAX_NAME_LENGTH)) /** Optional fields fall back to absent instead of discarding the whole peer. */ const lenient = (schema: T) => v.fallback(v.optional(schema), undefined) const color = v.object({ r: unit, g: unit, b: unit, a: v.fallback(unit, 1) }) const point = v.object({ x: finite, y: finite, pageId: id }) +const personPoint = v.object({ + ...point.entries, + zoom: lenient(v.pipe(finite, v.minValue(0.02), v.maxValue(256))) +}) const selection = v.pipe(v.array(id), v.maxLength(MAX_SELECTION)) const agent = v.object({ id, name, kind: v.picklist(AGENT_KINDS), - model: lenient(v.pipe(v.string(), v.maxLength(MAX_NAME))), + model: lenient(v.pipe(v.string(), v.maxLength(MAX_NAME_LENGTH))), status: v.picklist(AGENT_STATUSES), cursor: lenient(point), selection: lenient(selection) @@ -34,7 +38,7 @@ const agent = v.object({ const peerState = v.object({ user: v.object({ name: lenient(name), color: lenient(color) }), - cursor: lenient(point), + cursor: lenient(personPoint), selection: lenient(selection), agents: v.fallback( v.optional( diff --git a/src/app/presence/types.ts b/src/app/presence/types.ts index d0c8edc48..38b90f10f 100644 --- a/src/app/presence/types.ts +++ b/src/app/presence/types.ts @@ -10,6 +10,14 @@ export interface PresencePoint { pageId: string } +/** A person's pointer, with the zoom they view it at so followers can match it. */ +export interface PersonPoint extends PresencePoint { + zoom?: number +} + +/** Who to keep in view: a person in the room, or any agent, ours or theirs. */ +export type FollowTarget = { kind: 'person'; clientId: number } | { kind: 'agent'; agentId: string } + /** An agent as its owner publishes it: metadata only, never prompts or tool arguments. */ export interface AgentPresence { id: string diff --git a/src/components/CollabPanel/CollabAvatarStack.vue b/src/components/CollabPanel/CollabAvatarStack.vue deleted file mode 100644 index 42ecfa1f9..000000000 --- a/src/components/CollabPanel/CollabAvatarStack.vue +++ /dev/null @@ -1,54 +0,0 @@ - - - diff --git a/src/components/CollabPanel/CollabPanel.vue b/src/components/CollabPanel/CollabPanel.vue deleted file mode 100644 index 2b3998055..000000000 --- a/src/components/CollabPanel/CollabPanel.vue +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/src/components/EditorCanvas.vue b/src/components/EditorCanvas.vue index 3f901662b..77dc16d56 100644 --- a/src/components/EditorCanvas.vue +++ b/src/components/EditorCanvas.vue @@ -7,7 +7,7 @@ import { PopoverPortal, PopoverRoot } from 'reka-ui' -import { computed, onUnmounted, ref, watch, type Component } from 'vue' +import { computed, onUnmounted, ref, useTemplateRef, watch, type Component } from 'vue' import IconLucidePanelBottom from '~icons/lucide/panel-bottom' import IconLucidePanelLeft from '~icons/lucide/panel-left' import IconLucidePanelRight from '~icons/lucide/panel-right' @@ -30,8 +30,10 @@ import { useCollabInjected } from '@/app/collab/use' import { useEditorStore } from '@/app/editor/active-store' import { useCanvasCollaborationAwareness } from '@/app/editor/canvas/collaboration-awareness' import { createCanvasContextSelection } from '@/app/editor/canvas/context-selection' +import { useFollowView } from '@/app/presence/follow-view' import { appRuntimeConfig } from '@/app/runtime/config' import PreparationOverlay from '@/components/preparation/canvas/Overlay.vue' +import FollowFrame from '@/components/presence/FollowFrame.vue' import CanvasMenu from './canvas/CanvasMenu.vue' import CanvasLabelEditor from './canvas/labels/CanvasLabelEditor.vue' @@ -48,6 +50,7 @@ const sceneCanvasRef = ref(null) const canvasRef = ref(null) const isActivePane = computed(() => !paneId || store.activePaneId.value === paneId) +const followView = useFollowView(useTemplateRef('area')) function activatePane() { if (paneId) store.setActivePane(paneId) @@ -176,6 +179,7 @@ const cursor = computed(() => toolCursor(store.state.activeTool, cursorOverride.
toolCursor(store.state.activeTool, cursorOverride. + import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui' -import { tv } from 'tailwind-variants' import { colorToCSS } from '@open-pencil/scene-graph/color' @@ -8,14 +7,14 @@ import { initials } from '@/app/shell/ui' import HudButton from '@/components/mobile-hud/HudButton.vue' import { useMobileHudContext } from '@/components/MobileHud/context' import AppButton from '@/components/ui/button/AppButton.vue' -import collaborationTheme from '@/theme/collaboration' +import { avatar } from '@/theme/collaboration/avatar' +import { presencePopover } from '@/theme/mobile/presence-popover' const hud = useMobileHudContext() -const collaboration = tv(collaborationTheme) -const styles = collaboration({ size: 'md' }) +const styles = presencePopover() function peerAvatarClass(following: boolean) { - return collaboration({ size: 'md', following }).avatar() + return avatar({ size: 'md', following, interactive: true }) } @@ -23,7 +22,7 @@ function peerAvatarClass(following: boolean) { - + @@ -32,7 +31,7 @@ function peerAvatarClass(following: boolean) { :side-offset="8" side="bottom" align="center" - :class="styles.presenceContent()" + :class="styles.content()" >
{{ hud.messages.inThisRoom }} @@ -40,7 +39,7 @@ function peerAvatarClass(following: boolean) {
{{ initials(hud.collabState.localName || 'You') }} @@ -59,7 +58,7 @@ function peerAvatarClass(following: boolean) { @click="hud.toggleFollowPeer(peer.clientId)" >
{{ initials(peer.name) }} diff --git a/src/components/collab-panel/CollabAvatarStack.vue b/src/components/collab-panel/CollabAvatarStack.vue new file mode 100644 index 000000000..da3c03954 --- /dev/null +++ b/src/components/collab-panel/CollabAvatarStack.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/components/collab-panel/CollabPanel.vue b/src/components/collab-panel/CollabPanel.vue new file mode 100644 index 000000000..b1c9a5730 --- /dev/null +++ b/src/components/collab-panel/CollabPanel.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/CollabPanel/CollabSharePopover.vue b/src/components/collab-panel/CollabSharePopover.vue similarity index 60% rename from src/components/CollabPanel/CollabSharePopover.vue rename to src/components/collab-panel/CollabSharePopover.vue index 819c4033c..4ae581aa3 100644 --- a/src/components/CollabPanel/CollabSharePopover.vue +++ b/src/components/collab-panel/CollabSharePopover.vue @@ -1,14 +1,13 @@