Merge branch 'dev'
This commit is contained in:
commit
db00636be2
|
|
@ -11,6 +11,7 @@ import { computeMembranePermeability } from "./connection-types";
|
|||
import { makeChangeMessage, parseChangeMessage } from "../shared/local-first/change-message";
|
||||
import type { HistoryEntry } from "../shared/components/rstack-history-panel";
|
||||
import type { EventEntry } from "./event-bus";
|
||||
import type { CommentPinData } from "../shared/comment-pin-types";
|
||||
|
||||
// Shape data stored in Automerge document
|
||||
export interface ShapeData {
|
||||
|
|
@ -136,6 +137,8 @@ export interface CommunityDoc {
|
|||
layerViewMode?: "flat" | "stack";
|
||||
/** Pub/sub event log — bounded ring buffer (last 100 entries) */
|
||||
eventLog?: EventEntry[];
|
||||
/** Comment pins — Figma-style overlay markers */
|
||||
commentPins?: { [pinId: string]: CommentPinData };
|
||||
}
|
||||
|
||||
type SyncState = Automerge.SyncState;
|
||||
|
|
@ -844,6 +847,11 @@ export class CommunitySync extends EventTarget {
|
|||
this.dispatchEvent(new CustomEvent("eventlog-changed"));
|
||||
}
|
||||
|
||||
// Notify comment pin manager of any pin data
|
||||
if (this.#doc.commentPins && Object.keys(this.#doc.commentPins).length > 0) {
|
||||
this.dispatchEvent(new CustomEvent("comment-pins-changed"));
|
||||
}
|
||||
|
||||
// Debounce the synced event — during initial sync negotiation, #applyDocToDOM()
|
||||
// is called for every Automerge sync message (100+ round-trips). Debounce to
|
||||
// fire once after the burst settles. Only fires once per connection cycle.
|
||||
|
|
@ -862,6 +870,7 @@ export class CommunitySync extends EventTarget {
|
|||
*/
|
||||
#applyPatchesToDOM(patches: Automerge.Patch[]): void {
|
||||
let eventLogChanged = false;
|
||||
let commentPinsChanged = false;
|
||||
|
||||
for (const patch of patches) {
|
||||
const path = patch.path;
|
||||
|
|
@ -872,6 +881,12 @@ export class CommunitySync extends EventTarget {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Detect commentPins changes
|
||||
if (path[0] === "commentPins") {
|
||||
commentPinsChanged = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle shape updates: ["shapes", shapeId, ...]
|
||||
if (path[0] === "shapes" && typeof path[1] === "string") {
|
||||
const shapeId = path[1];
|
||||
|
|
@ -913,6 +928,11 @@ export class CommunitySync extends EventTarget {
|
|||
if (eventLogChanged) {
|
||||
this.dispatchEvent(new CustomEvent("eventlog-changed"));
|
||||
}
|
||||
|
||||
// Notify comment pin manager of remote pin changes
|
||||
if (commentPinsChanged) {
|
||||
this.dispatchEvent(new CustomEvent("comment-pins-changed"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,833 @@
|
|||
/**
|
||||
* CommentPinManager — Figma-style comment pins on the canvas.
|
||||
* Follows the PresenceManager pattern: overlay DOM layer with camera tracking.
|
||||
*/
|
||||
|
||||
import * as Automerge from "@automerge/automerge";
|
||||
import type { CommunitySync } from "./community-sync";
|
||||
import type {
|
||||
CommentPinAnchor,
|
||||
CommentPinMessage,
|
||||
CommentPinData,
|
||||
} from "../shared/comment-pin-types";
|
||||
|
||||
interface SpaceMember {
|
||||
did: string;
|
||||
displayName: string | null;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export class CommentPinManager {
|
||||
#container: HTMLElement;
|
||||
#canvasContent: HTMLElement;
|
||||
#sync: CommunitySync;
|
||||
#spaceSlug: string;
|
||||
#panX = 0;
|
||||
#panY = 0;
|
||||
#scale = 1;
|
||||
#pinLayer: HTMLElement;
|
||||
#popover: HTMLElement;
|
||||
#placementMode = false;
|
||||
#showResolved = false;
|
||||
#openPinId: string | null = null;
|
||||
#members: SpaceMember[] | null = null;
|
||||
#mentionDropdown: HTMLElement | null = null;
|
||||
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
canvasContent: HTMLElement,
|
||||
sync: CommunitySync,
|
||||
spaceSlug: string,
|
||||
) {
|
||||
this.#container = container;
|
||||
this.#canvasContent = canvasContent;
|
||||
this.#sync = sync;
|
||||
this.#spaceSlug = spaceSlug;
|
||||
|
||||
// Create pin overlay layer
|
||||
this.#pinLayer = document.createElement("div");
|
||||
this.#pinLayer.id = "comment-pins-layer";
|
||||
this.#pinLayer.style.cssText = `
|
||||
position: absolute; top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
pointer-events: none; z-index: 9998; overflow: visible;
|
||||
`;
|
||||
this.#container.appendChild(this.#pinLayer);
|
||||
|
||||
// Create shared popover
|
||||
this.#popover = document.createElement("div");
|
||||
this.#popover.id = "comment-pin-popover";
|
||||
this.#popover.style.cssText = `
|
||||
display: none; position: absolute; z-index: 10001;
|
||||
background: #1e1e2e; border: 1px solid #444; border-radius: 10px;
|
||||
width: 300px; max-height: 400px; overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5); pointer-events: auto;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
color: #e0e0e0; font-size: 13px;
|
||||
`;
|
||||
this.#container.appendChild(this.#popover);
|
||||
|
||||
// Listen for remote comment pin changes
|
||||
this.#sync.addEventListener("comment-pins-changed", () => this.#renderAllPins());
|
||||
this.#sync.addEventListener("synced", () => this.#renderAllPins());
|
||||
|
||||
// Listen for shape transforms to track attached pins
|
||||
this.#canvasContent.addEventListener("folk-transform", (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target?.id) this.#refreshPinsForShape(target.id);
|
||||
});
|
||||
|
||||
// Close popover on outside click
|
||||
document.addEventListener("pointerdown", (e) => {
|
||||
if (this.#popover.style.display === "none") return;
|
||||
if (this.#popover.contains(e.target as Node)) return;
|
||||
// Don't close if clicking a pin marker
|
||||
if ((e.target as HTMLElement)?.closest?.(".comment-pin-marker")) return;
|
||||
this.closePopover();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Camera ──
|
||||
|
||||
setCamera(panX: number, panY: number, scale: number) {
|
||||
this.#panX = panX;
|
||||
this.#panY = panY;
|
||||
this.#scale = scale;
|
||||
this.#repositionAllPins();
|
||||
this.#repositionPopover();
|
||||
|
||||
// Hide pins when zoomed out very far
|
||||
this.#pinLayer.style.display = scale < 0.15 ? "none" : "";
|
||||
}
|
||||
|
||||
// ── Placement Mode ──
|
||||
|
||||
get placementMode() {
|
||||
return this.#placementMode;
|
||||
}
|
||||
|
||||
enterPinPlacementMode() {
|
||||
this.#placementMode = true;
|
||||
this.#container.style.cursor = "crosshair";
|
||||
}
|
||||
|
||||
exitPinPlacementMode() {
|
||||
this.#placementMode = false;
|
||||
this.#container.style.cursor = "";
|
||||
}
|
||||
|
||||
// ── Pin CRUD ──
|
||||
|
||||
createPin(anchor: CommentPinAnchor): string {
|
||||
const pinId = crypto.randomUUID();
|
||||
const did = this.#getLocalDID();
|
||||
const name = this.#getLocalUsername();
|
||||
|
||||
const pinData: CommentPinData = {
|
||||
id: pinId,
|
||||
anchor,
|
||||
resolved: false,
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
createdBy: did,
|
||||
createdByName: name,
|
||||
};
|
||||
|
||||
const newDoc = Automerge.change(this.#sync.doc, "Add comment pin", (doc) => {
|
||||
if (!doc.commentPins) doc.commentPins = {};
|
||||
doc.commentPins[pinId] = JSON.parse(JSON.stringify(pinData));
|
||||
});
|
||||
this.#sync._applyDocChange(newDoc);
|
||||
|
||||
this.#renderAllPins();
|
||||
this.#openPinPopover(pinId, true);
|
||||
return pinId;
|
||||
}
|
||||
|
||||
createPinOnShape(shapeId: string) {
|
||||
const shape = document.getElementById(shapeId) as HTMLElement | null;
|
||||
if (!shape) return;
|
||||
|
||||
const anchor: CommentPinAnchor = {
|
||||
type: "shape",
|
||||
shapeId,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
};
|
||||
this.createPin(anchor);
|
||||
}
|
||||
|
||||
handleCanvasClick(worldX: number, worldY: number) {
|
||||
if (!this.#placementMode) return false;
|
||||
|
||||
// Check if clicking on a shape
|
||||
const shapeEl = document.elementFromPoint(
|
||||
worldX * this.#scale + this.#panX,
|
||||
worldY * this.#scale + this.#panY,
|
||||
)?.closest?.("folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-prompt, folk-zine-gen, folk-image-gen, folk-video-gen, folk-embed, folk-chat, folk-rapp") as HTMLElement | null;
|
||||
|
||||
let anchor: CommentPinAnchor;
|
||||
if (shapeEl?.id) {
|
||||
const shapeRect = shapeEl.getBoundingClientRect();
|
||||
const canvasRect = this.#container.getBoundingClientRect();
|
||||
// Offset relative to shape origin
|
||||
const shapeWorldX = (shapeRect.left - canvasRect.left - this.#panX) / this.#scale;
|
||||
const shapeWorldY = (shapeRect.top - canvasRect.top - this.#panY) / this.#scale;
|
||||
anchor = {
|
||||
type: "shape",
|
||||
shapeId: shapeEl.id,
|
||||
offsetX: worldX - shapeWorldX,
|
||||
offsetY: worldY - shapeWorldY,
|
||||
};
|
||||
} else {
|
||||
anchor = { type: "canvas", offsetX: worldX, offsetY: worldY };
|
||||
}
|
||||
|
||||
this.createPin(anchor);
|
||||
this.exitPinPlacementMode();
|
||||
return true;
|
||||
}
|
||||
|
||||
addMessage(pinId: string, text: string) {
|
||||
const did = this.#getLocalDID();
|
||||
const name = this.#getLocalUsername();
|
||||
|
||||
// Extract @mentions
|
||||
const mentionedDids = this.#extractMentionDids(text);
|
||||
|
||||
const msg: CommentPinMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
authorId: did,
|
||||
authorName: name,
|
||||
text,
|
||||
mentionedDids: mentionedDids.length > 0 ? mentionedDids : undefined,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
const newDoc = Automerge.change(this.#sync.doc, "Add comment", (doc) => {
|
||||
if (doc.commentPins?.[pinId]) {
|
||||
doc.commentPins[pinId].messages.push(JSON.parse(JSON.stringify(msg)));
|
||||
}
|
||||
});
|
||||
this.#sync._applyDocChange(newDoc);
|
||||
|
||||
// Fire-and-forget notification for mentions
|
||||
if (mentionedDids.length > 0) {
|
||||
this.#notifyMentions(pinId, did, name, mentionedDids);
|
||||
}
|
||||
|
||||
// Re-render popover if open
|
||||
if (this.#openPinId === pinId) {
|
||||
this.#openPinPopover(pinId, false);
|
||||
}
|
||||
this.#renderAllPins();
|
||||
}
|
||||
|
||||
resolvePin(pinId: string) {
|
||||
const newDoc = Automerge.change(this.#sync.doc, "Resolve comment pin", (doc) => {
|
||||
if (doc.commentPins?.[pinId]) {
|
||||
doc.commentPins[pinId].resolved = true;
|
||||
}
|
||||
});
|
||||
this.#sync._applyDocChange(newDoc);
|
||||
this.closePopover();
|
||||
this.#renderAllPins();
|
||||
}
|
||||
|
||||
unresolvePin(pinId: string) {
|
||||
const newDoc = Automerge.change(this.#sync.doc, "Unresolve comment pin", (doc) => {
|
||||
if (doc.commentPins?.[pinId]) {
|
||||
doc.commentPins[pinId].resolved = false;
|
||||
}
|
||||
});
|
||||
this.#sync._applyDocChange(newDoc);
|
||||
this.#renderAllPins();
|
||||
}
|
||||
|
||||
deletePin(pinId: string) {
|
||||
const newDoc = Automerge.change(this.#sync.doc, "Delete comment pin", (doc) => {
|
||||
if (doc.commentPins?.[pinId]) {
|
||||
delete doc.commentPins[pinId];
|
||||
}
|
||||
});
|
||||
this.#sync._applyDocChange(newDoc);
|
||||
this.closePopover();
|
||||
this.#renderAllPins();
|
||||
}
|
||||
|
||||
toggleShowResolved() {
|
||||
this.#showResolved = !this.#showResolved;
|
||||
this.#renderAllPins();
|
||||
return this.#showResolved;
|
||||
}
|
||||
|
||||
closePopover() {
|
||||
this.#popover.style.display = "none";
|
||||
this.#openPinId = null;
|
||||
this.#closeMentionDropdown();
|
||||
}
|
||||
|
||||
openPinById(pinId: string) {
|
||||
const pins = this.#sync.doc.commentPins || {};
|
||||
if (!pins[pinId]) return;
|
||||
this.#renderAllPins();
|
||||
this.#openPinPopover(pinId, false);
|
||||
|
||||
// Pan to the pin
|
||||
const pos = this.#getPinWorldPosition(pins[pinId]);
|
||||
if (pos) {
|
||||
// Dispatch a custom event so canvas.html can animate to position
|
||||
this.#container.dispatchEvent(
|
||||
new CustomEvent("comment-pin-navigate", { detail: { x: pos.x, y: pos.y } }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ──
|
||||
|
||||
#renderAllPins() {
|
||||
const pins = this.#sync.doc.commentPins || {};
|
||||
const sortedPins = Object.values(pins).sort((a, b) => a.createdAt - b.createdAt);
|
||||
|
||||
// Detect orphaned pins (shape deleted)
|
||||
const orphanedPins = new Set<string>();
|
||||
for (const pin of sortedPins) {
|
||||
if (pin.anchor.type === "shape" && pin.anchor.shapeId) {
|
||||
const shapeExists = this.#sync.doc.shapes?.[pin.anchor.shapeId];
|
||||
const shapeDeleted = shapeExists && (shapeExists as any).deleted === true;
|
||||
if (!shapeExists || shapeDeleted) {
|
||||
orphanedPins.add(pin.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear existing markers
|
||||
this.#pinLayer.innerHTML = "";
|
||||
|
||||
let displayIndex = 0;
|
||||
for (const pin of sortedPins) {
|
||||
displayIndex++;
|
||||
|
||||
if (pin.resolved && !this.#showResolved) continue;
|
||||
|
||||
const pos = this.#getPinWorldPosition(pin);
|
||||
if (!pos) continue;
|
||||
|
||||
const screenX = pos.x * this.#scale + this.#panX;
|
||||
const screenY = pos.y * this.#scale + this.#panY;
|
||||
const isOrphaned = orphanedPins.has(pin.id);
|
||||
|
||||
const marker = document.createElement("div");
|
||||
marker.className = "comment-pin-marker";
|
||||
marker.dataset.pinId = pin.id;
|
||||
marker.style.cssText = `
|
||||
position: absolute;
|
||||
left: ${screenX}px; top: ${screenY}px;
|
||||
width: 28px; height: 28px;
|
||||
transform: translate(-50%, -100%);
|
||||
pointer-events: auto; cursor: pointer;
|
||||
transition: left 0.05s, top 0.05s;
|
||||
z-index: ${pin.id === this.#openPinId ? 10000 : 9998};
|
||||
`;
|
||||
|
||||
const bgColor = pin.resolved ? "#666" : isOrphaned ? "#ef4444" : "#8b5cf6";
|
||||
const border = isOrphaned ? "2px dashed #fbbf24" : "2px solid rgba(255,255,255,0.3)";
|
||||
const opacity = pin.resolved ? "0.5" : "1";
|
||||
|
||||
marker.innerHTML = `
|
||||
<div style="
|
||||
width: 28px; height: 28px;
|
||||
background: ${bgColor}; border-radius: 50% 50% 50% 0;
|
||||
transform: rotate(-45deg);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border: ${border}; opacity: ${opacity};
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
">
|
||||
<span style="
|
||||
transform: rotate(45deg);
|
||||
color: white; font-size: 11px; font-weight: 700;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
">${displayIndex}</span>
|
||||
</div>
|
||||
${this.#scale >= 0.4 && pin.messages.length > 0 ? `
|
||||
<div style="
|
||||
position: absolute; top: -6px; right: -6px;
|
||||
background: #ef4444; color: white; font-size: 9px;
|
||||
min-width: 16px; height: 16px; border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; transform: rotate(45deg);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
">${pin.messages.length}</div>
|
||||
` : ""}
|
||||
`;
|
||||
|
||||
marker.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#openPinPopover(pin.id, false);
|
||||
});
|
||||
|
||||
this.#pinLayer.appendChild(marker);
|
||||
}
|
||||
}
|
||||
|
||||
#repositionAllPins() {
|
||||
const pins = this.#sync.doc.commentPins || {};
|
||||
const markers = this.#pinLayer.querySelectorAll(".comment-pin-marker") as NodeListOf<HTMLElement>;
|
||||
|
||||
for (const marker of markers) {
|
||||
const pinId = marker.dataset.pinId;
|
||||
if (!pinId || !pins[pinId]) continue;
|
||||
|
||||
const pos = this.#getPinWorldPosition(pins[pinId]);
|
||||
if (!pos) continue;
|
||||
|
||||
const screenX = pos.x * this.#scale + this.#panX;
|
||||
const screenY = pos.y * this.#scale + this.#panY;
|
||||
marker.style.left = `${screenX}px`;
|
||||
marker.style.top = `${screenY}px`;
|
||||
|
||||
// Hide labels when zoomed out
|
||||
const badge = marker.querySelector("div[style*='top: -6px']") as HTMLElement | null;
|
||||
if (badge) badge.style.display = this.#scale >= 0.4 ? "" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
#repositionPopover() {
|
||||
if (!this.#openPinId || this.#popover.style.display === "none") return;
|
||||
const pins = this.#sync.doc.commentPins || {};
|
||||
const pin = pins[this.#openPinId];
|
||||
if (!pin) return;
|
||||
|
||||
const pos = this.#getPinWorldPosition(pin);
|
||||
if (!pos) return;
|
||||
|
||||
const screenX = pos.x * this.#scale + this.#panX;
|
||||
const screenY = pos.y * this.#scale + this.#panY;
|
||||
this.#popover.style.left = `${screenX + 16}px`;
|
||||
this.#popover.style.top = `${screenY}px`;
|
||||
}
|
||||
|
||||
#refreshPinsForShape(shapeId: string) {
|
||||
const pins = this.#sync.doc.commentPins || {};
|
||||
let needsReposition = false;
|
||||
|
||||
for (const pin of Object.values(pins)) {
|
||||
if (pin.anchor.type === "shape" && pin.anchor.shapeId === shapeId) {
|
||||
needsReposition = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsReposition) {
|
||||
this.#repositionAllPins();
|
||||
this.#repositionPopover();
|
||||
}
|
||||
}
|
||||
|
||||
#getPinWorldPosition(pin: CommentPinData): { x: number; y: number } | null {
|
||||
if (pin.anchor.type === "canvas") {
|
||||
return { x: pin.anchor.offsetX, y: pin.anchor.offsetY };
|
||||
}
|
||||
|
||||
if (pin.anchor.type === "shape" && pin.anchor.shapeId) {
|
||||
const shapeData = this.#sync.doc.shapes?.[pin.anchor.shapeId];
|
||||
if (!shapeData || (shapeData as any).deleted) {
|
||||
// Orphaned — fall back to anchor offset as world coords
|
||||
return { x: pin.anchor.offsetX, y: pin.anchor.offsetY };
|
||||
}
|
||||
return {
|
||||
x: shapeData.x + pin.anchor.offsetX,
|
||||
y: shapeData.y + pin.anchor.offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Popover ──
|
||||
|
||||
#openPinPopover(pinId: string, focusInput: boolean) {
|
||||
const pins = this.#sync.doc.commentPins || {};
|
||||
const pin = pins[pinId];
|
||||
if (!pin) return;
|
||||
|
||||
this.#openPinId = pinId;
|
||||
const pos = this.#getPinWorldPosition(pin);
|
||||
if (!pos) return;
|
||||
|
||||
const screenX = pos.x * this.#scale + this.#panX;
|
||||
const screenY = pos.y * this.#scale + this.#panY;
|
||||
|
||||
// Sequential pin number
|
||||
const sortedPins = Object.values(pins).sort((a, b) => a.createdAt - b.createdAt);
|
||||
const pinIndex = sortedPins.findIndex((p) => p.id === pinId) + 1;
|
||||
|
||||
const isOrphaned =
|
||||
pin.anchor.type === "shape" &&
|
||||
pin.anchor.shapeId &&
|
||||
(!this.#sync.doc.shapes?.[pin.anchor.shapeId] ||
|
||||
(this.#sync.doc.shapes[pin.anchor.shapeId] as any).deleted);
|
||||
|
||||
let html = `
|
||||
<div style="padding: 10px 12px; border-bottom: 1px solid #333; display: flex; align-items: center; justify-content: space-between;">
|
||||
<span style="font-weight: 700; color: #a78bfa;">Pin #${pinIndex}</span>
|
||||
<div style="display: flex; gap: 4px;">
|
||||
${pin.resolved
|
||||
? `<button class="cp-action" data-action="unresolve" title="Reopen">↩</button>`
|
||||
: `<button class="cp-action" data-action="resolve" title="Resolve">✓</button>`}
|
||||
<button class="cp-action" data-action="remind" title="Set reminder">📅</button>
|
||||
<button class="cp-action" data-action="delete" title="Delete" style="color: #ef4444;">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
${isOrphaned ? `<div style="padding: 6px 12px; background: #44200a; color: #fbbf24; font-size: 11px;">⚠ Attached shape was deleted</div>` : ""}
|
||||
`;
|
||||
|
||||
// Messages
|
||||
if (pin.messages.length > 0) {
|
||||
html += `<div style="max-height: 240px; overflow-y: auto; padding: 8px 12px;">`;
|
||||
for (const msg of pin.messages) {
|
||||
const time = new Date(msg.createdAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
html += `
|
||||
<div style="margin-bottom: 10px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: baseline;">
|
||||
<span style="font-weight: 600; color: #c4b5fd; font-size: 12px;">${this.#escapeHtml(msg.authorName)}</span>
|
||||
<span style="color: #666; font-size: 10px;">${time}</span>
|
||||
</div>
|
||||
<div style="margin-top: 3px; line-height: 1.4; word-break: break-word;">${this.#formatMessageText(msg.text)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// Input
|
||||
html += `
|
||||
<div style="padding: 8px 12px; border-top: 1px solid #333; position: relative;">
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<input type="text" class="cp-input" placeholder="Add a comment... (@ to mention)"
|
||||
style="flex: 1; background: #2a2a3a; border: 1px solid #444; border-radius: 6px;
|
||||
padding: 6px 10px; color: #e0e0e0; font-size: 13px; outline: none;"
|
||||
/>
|
||||
<button class="cp-send" style="
|
||||
background: #8b5cf6; color: white; border: none; border-radius: 6px;
|
||||
padding: 6px 12px; cursor: pointer; font-size: 13px; font-weight: 600;
|
||||
">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.#popover.innerHTML = html;
|
||||
this.#popover.style.display = "block";
|
||||
this.#popover.style.left = `${screenX + 16}px`;
|
||||
this.#popover.style.top = `${screenY}px`;
|
||||
|
||||
// Wire up actions
|
||||
this.#popover.querySelectorAll(".cp-action").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
const action = (e.currentTarget as HTMLElement).dataset.action;
|
||||
if (action === "resolve") this.resolvePin(pinId);
|
||||
else if (action === "unresolve") this.unresolvePin(pinId);
|
||||
else if (action === "delete") this.deletePin(pinId);
|
||||
else if (action === "remind") this.#createReminder(pinId);
|
||||
});
|
||||
});
|
||||
|
||||
// Wire up input
|
||||
const input = this.#popover.querySelector(".cp-input") as HTMLInputElement;
|
||||
const sendBtn = this.#popover.querySelector(".cp-send") as HTMLButtonElement;
|
||||
|
||||
const submitComment = () => {
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
this.addMessage(pinId, text);
|
||||
input.value = "";
|
||||
};
|
||||
|
||||
sendBtn.addEventListener("click", submitComment);
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submitComment();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
this.closePopover();
|
||||
}
|
||||
});
|
||||
|
||||
// @mention autocomplete
|
||||
input.addEventListener("input", () => this.#handleMentionInput(input));
|
||||
|
||||
if (focusInput) {
|
||||
requestAnimationFrame(() => input.focus());
|
||||
}
|
||||
|
||||
// Style action buttons
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
.cp-action { background: none; border: 1px solid #444; border-radius: 4px;
|
||||
padding: 2px 6px; cursor: pointer; color: #ccc; font-size: 13px; }
|
||||
.cp-action:hover { background: #333; }
|
||||
`;
|
||||
this.#popover.prepend(style);
|
||||
}
|
||||
|
||||
// ── @Mention Autocomplete ──
|
||||
|
||||
async #fetchMembers() {
|
||||
if (this.#members) return this.#members;
|
||||
try {
|
||||
const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}");
|
||||
const res = await fetch(`/${this.#spaceSlug}/api/space-members`, {
|
||||
headers: sess?.accessToken ? { Authorization: `Bearer ${sess.accessToken}` } : {},
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
this.#members = data.members || [];
|
||||
return this.#members!;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async #handleMentionInput(input: HTMLInputElement) {
|
||||
const val = input.value;
|
||||
const cursorPos = input.selectionStart || 0;
|
||||
|
||||
// Find @ before cursor
|
||||
const before = val.slice(0, cursorPos);
|
||||
const atMatch = before.match(/@(\w*)$/);
|
||||
|
||||
if (!atMatch) {
|
||||
this.#closeMentionDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
const query = atMatch[1].toLowerCase();
|
||||
const members = await this.#fetchMembers();
|
||||
const filtered = members.filter(
|
||||
(m) =>
|
||||
m.username.toLowerCase().includes(query) ||
|
||||
(m.displayName && m.displayName.toLowerCase().includes(query)),
|
||||
);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
this.#closeMentionDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
this.#showMentionDropdown(filtered, input, atMatch.index!);
|
||||
}
|
||||
|
||||
#showMentionDropdown(
|
||||
members: SpaceMember[],
|
||||
input: HTMLInputElement,
|
||||
atIndex: number,
|
||||
) {
|
||||
this.#closeMentionDropdown();
|
||||
|
||||
const dropdown = document.createElement("div");
|
||||
dropdown.className = "cp-mention-dropdown";
|
||||
dropdown.style.cssText = `
|
||||
position: absolute; bottom: 100%; left: 12px; right: 12px;
|
||||
background: #2a2a3a; border: 1px solid #555; border-radius: 6px;
|
||||
max-height: 150px; overflow-y: auto; z-index: 10002;
|
||||
`;
|
||||
|
||||
for (const m of members.slice(0, 8)) {
|
||||
const item = document.createElement("div");
|
||||
item.style.cssText = `
|
||||
padding: 6px 10px; cursor: pointer; font-size: 12px;
|
||||
display: flex; justify-content: space-between;
|
||||
`;
|
||||
item.innerHTML = `
|
||||
<span style="font-weight: 600;">${this.#escapeHtml(m.displayName || m.username)}</span>
|
||||
<span style="color: #888;">@${this.#escapeHtml(m.username)}</span>
|
||||
`;
|
||||
item.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault(); // prevent input blur
|
||||
const val = input.value;
|
||||
const before = val.slice(0, atIndex);
|
||||
const after = val.slice(input.selectionStart || atIndex);
|
||||
input.value = `${before}@${m.username} ${after}`;
|
||||
input.focus();
|
||||
const newPos = atIndex + m.username.length + 2;
|
||||
input.setSelectionRange(newPos, newPos);
|
||||
this.#closeMentionDropdown();
|
||||
});
|
||||
item.addEventListener("mouseenter", () => {
|
||||
item.style.background = "#3a3a4a";
|
||||
});
|
||||
item.addEventListener("mouseleave", () => {
|
||||
item.style.background = "";
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
}
|
||||
|
||||
const inputContainer = input.closest("div[style*='position: relative']");
|
||||
if (inputContainer) {
|
||||
inputContainer.appendChild(dropdown);
|
||||
}
|
||||
this.#mentionDropdown = dropdown;
|
||||
}
|
||||
|
||||
#closeMentionDropdown() {
|
||||
if (this.#mentionDropdown) {
|
||||
this.#mentionDropdown.remove();
|
||||
this.#mentionDropdown = null;
|
||||
}
|
||||
}
|
||||
|
||||
#extractMentionDids(text: string): string[] {
|
||||
const mentions = text.match(/@(\w+)/g);
|
||||
if (!mentions || !this.#members) return [];
|
||||
|
||||
const dids: string[] = [];
|
||||
for (const mention of mentions) {
|
||||
const username = mention.slice(1).toLowerCase();
|
||||
const member = this.#members.find(
|
||||
(m) => m.username.toLowerCase() === username,
|
||||
);
|
||||
if (member) dids.push(member.did);
|
||||
}
|
||||
return dids;
|
||||
}
|
||||
|
||||
// ── Notifications ──
|
||||
|
||||
async #notifyMentions(
|
||||
pinId: string,
|
||||
authorDid: string,
|
||||
authorName: string,
|
||||
mentionedDids: string[],
|
||||
) {
|
||||
const pins = this.#sync.doc.commentPins || {};
|
||||
const sortedPins = Object.values(pins).sort((a, b) => a.createdAt - b.createdAt);
|
||||
const pinIndex = sortedPins.findIndex((p) => p.id === pinId) + 1;
|
||||
|
||||
try {
|
||||
const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}");
|
||||
await fetch(`/${this.#spaceSlug}/api/comment-pins/notify-mention`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(sess?.accessToken ? { Authorization: `Bearer ${sess.accessToken}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pinId,
|
||||
authorDid,
|
||||
authorName,
|
||||
mentionedDids,
|
||||
pinIndex,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// Silent fail — notification is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reminder ──
|
||||
|
||||
async #createReminder(pinId: string) {
|
||||
const pins = this.#sync.doc.commentPins || {};
|
||||
const pin = pins[pinId];
|
||||
if (!pin) return;
|
||||
|
||||
const sortedPins = Object.values(pins).sort((a, b) => a.createdAt - b.createdAt);
|
||||
const pinIndex = sortedPins.findIndex((p) => p.id === pinId) + 1;
|
||||
const firstMsg = pin.messages[0]?.text || "Comment pin";
|
||||
|
||||
// Tomorrow 9 AM
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(9, 0, 0, 0);
|
||||
|
||||
// Fetch user email
|
||||
let email: string | false = false;
|
||||
try {
|
||||
const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}");
|
||||
if (sess?.accessToken) {
|
||||
const r = await fetch("/auth/api/account/security", {
|
||||
headers: { Authorization: `Bearer ${sess.accessToken}` },
|
||||
});
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
email = d.emailAddress || false;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const body: Record<string, any> = {
|
||||
title: `Comment #${pinIndex}: ${firstMsg.slice(0, 50)}`,
|
||||
remindAt: tomorrow.getTime(),
|
||||
allDay: true,
|
||||
syncToCalendar: true,
|
||||
sourceModule: "canvas-comments",
|
||||
sourceEntityId: pinId,
|
||||
sourceLabel: "Canvas Comment",
|
||||
sourceColor: "#8b5cf6",
|
||||
};
|
||||
if (email) body.notifyEmail = email;
|
||||
|
||||
const res = await fetch(`/${this.#spaceSlug}/rschedule/api/reminders`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Show brief feedback in popover
|
||||
const fb = document.createElement("div");
|
||||
fb.style.cssText =
|
||||
"padding: 6px 12px; background: #1a3a1a; color: #4ade80; font-size: 12px; text-align: center;";
|
||||
fb.textContent = "✓ Reminder set for tomorrow";
|
||||
this.#popover.prepend(fb);
|
||||
setTimeout(() => fb.remove(), 2500);
|
||||
}
|
||||
} catch {
|
||||
// Silent fail
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
#getLocalDID(): string {
|
||||
try {
|
||||
const sess = JSON.parse(localStorage.getItem("encryptid_session") || "");
|
||||
return sess?.claims?.sub || sess?.claims?.did || "anonymous";
|
||||
} catch {
|
||||
return "anonymous";
|
||||
}
|
||||
}
|
||||
|
||||
#getLocalUsername(): string {
|
||||
return localStorage.getItem("rspace-username") || "Anonymous";
|
||||
}
|
||||
|
||||
#escapeHtml(text: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
#formatMessageText(text: string): string {
|
||||
// Escape HTML first
|
||||
let safe = this.#escapeHtml(text);
|
||||
// Highlight @mentions
|
||||
safe = safe.replace(
|
||||
/@(\w+)/g,
|
||||
'<span style="color: #a78bfa; font-weight: 600;">@$1</span>',
|
||||
);
|
||||
return safe;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.#pinLayer.remove();
|
||||
this.#popover.remove();
|
||||
}
|
||||
}
|
||||
|
|
@ -110,6 +110,7 @@ export * from "./folk-group-frame";
|
|||
export * from "./community-sync";
|
||||
export * from "./presence";
|
||||
export * from "./event-bus";
|
||||
export * from "./folk-comment-pin";
|
||||
|
||||
// Offline support
|
||||
export * from "./offline-store";
|
||||
|
|
|
|||
|
|
@ -709,6 +709,56 @@ app.get("/api/modules/:moduleId/landing", (c) => {
|
|||
return c.json({ html, icon: mod.icon || "", name: mod.name || moduleId });
|
||||
});
|
||||
|
||||
// ── Comment Pin API ──
|
||||
import { listAllUsersWithTrust } from "../src/encryptid/db";
|
||||
|
||||
// Space members for @mention autocomplete
|
||||
app.get("/:space/api/space-members", async (c) => {
|
||||
const space = c.req.param("space");
|
||||
try {
|
||||
const users = await listAllUsersWithTrust(space);
|
||||
return c.json({
|
||||
members: users.map((u) => ({
|
||||
did: u.did,
|
||||
username: u.username,
|
||||
displayName: u.displayName,
|
||||
})),
|
||||
});
|
||||
} catch {
|
||||
return c.json({ members: [] });
|
||||
}
|
||||
});
|
||||
|
||||
// Mention notification
|
||||
app.post("/:space/api/comment-pins/notify-mention", async (c) => {
|
||||
const space = c.req.param("space");
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const { pinId, authorDid, authorName, mentionedDids, pinIndex } = body;
|
||||
if (!pinId || !authorDid || !mentionedDids?.length) {
|
||||
return c.json({ error: "Missing fields" }, 400);
|
||||
}
|
||||
for (const did of mentionedDids) {
|
||||
await notify({
|
||||
userDid: did,
|
||||
category: "module",
|
||||
eventType: "canvas_mention",
|
||||
title: `${authorName} mentioned you in a comment`,
|
||||
body: `Comment pin #${pinIndex || "?"} in ${space}`,
|
||||
spaceSlug: space,
|
||||
moduleId: "rspace",
|
||||
actionUrl: `/${space}/rspace#pin-${pinId}`,
|
||||
actorDid: authorDid,
|
||||
actorUsername: authorName,
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error("[comment-pins] notify error:", err);
|
||||
return c.json({ error: "Failed to send notification" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ── x402 test endpoint (payment-gated, supports on-chain + CRDT) ──
|
||||
import { setupX402FromEnv } from "../shared/x402/hono-middleware";
|
||||
import { setTokenVerifier } from "../shared/x402/crdt-scheme";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Comment Pin Types — shared between client and server.
|
||||
* Figma-style overlay comment markers on the canvas.
|
||||
*/
|
||||
|
||||
export interface CommentPinAnchor {
|
||||
type: 'shape' | 'canvas';
|
||||
shapeId?: string; // when type === 'shape'
|
||||
offsetX: number; // shape-relative or world coords
|
||||
offsetY: number;
|
||||
}
|
||||
|
||||
export interface CommentPinMessage {
|
||||
id: string;
|
||||
authorId: string; // DID
|
||||
authorName: string;
|
||||
text: string;
|
||||
mentionedDids?: string[];
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface CommentPinData {
|
||||
id: string;
|
||||
anchor: CommentPinAnchor;
|
||||
resolved: boolean;
|
||||
messages: CommentPinMessage[];
|
||||
createdAt: number;
|
||||
createdBy: string; // DID
|
||||
createdByName: string;
|
||||
}
|
||||
|
|
@ -2208,6 +2208,9 @@
|
|||
<button class="tool-btn" id="tool-text" title="Note (T)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 7 4 4 20 4 20 7"/><line x1="12" y1="4" x2="12" y2="20"/><line x1="8" y1="20" x2="16" y2="20"/></svg>
|
||||
</button>
|
||||
<button class="tool-btn" id="tool-comment" title="Leave Comment (/)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/></svg>
|
||||
</button>
|
||||
<span class="tool-sep"></span>
|
||||
<button class="tool-btn" id="tool-eraser" title="Eraser (E)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 20H7L3 16a1 1 0 010-1.4l9.6-9.6a1 1 0 011.4 0l7 7a1 1 0 010 1.4L15 20"/><line x1="18" y1="13" x2="11" y2="6"/></svg>
|
||||
|
|
@ -2506,6 +2509,7 @@
|
|||
FolkGroupFrame,
|
||||
onArrowPipeCreated,
|
||||
onArrowRemoved,
|
||||
CommentPinManager,
|
||||
} from "@lib";
|
||||
import { RStackIdentity } from "@shared/components/rstack-identity";
|
||||
import { RStackNotificationBell } from "@shared/components/rstack-notification-bell";
|
||||
|
|
@ -3186,6 +3190,9 @@
|
|||
const storedUsername = localStorage.getItem("rspace-username") || `User ${peerId.slice(0, 4)}`;
|
||||
const presence = new PresenceManager(canvas, peerId, storedUsername);
|
||||
|
||||
// Initialize Comment Pin Manager
|
||||
const pinManager = new CommentPinManager(canvas, canvasContent, sync, communitySlug);
|
||||
|
||||
// Track selected shapes for presence sharing and multi-select
|
||||
let selectedShapeIds = new Set();
|
||||
|
||||
|
|
@ -3617,6 +3624,13 @@
|
|||
// Refresh history panel with latest doc
|
||||
const hp = document.querySelector('rstack-history-panel');
|
||||
if (hp) hp.setDoc(sync.doc);
|
||||
|
||||
// Hash navigation for comment pins (#pin-{id})
|
||||
const hash = location.hash;
|
||||
if (hash.startsWith("#pin-")) {
|
||||
const pinId = hash.slice(5);
|
||||
setTimeout(() => pinManager.openPinById(pinId), 500);
|
||||
}
|
||||
});
|
||||
|
||||
// FUN: New — handle new shape from remote sync
|
||||
|
|
@ -4039,6 +4053,7 @@
|
|||
else if (key === "t") { document.getElementById("tool-text")?.click(); }
|
||||
else if (key === "a") { toggleConnectMode(); }
|
||||
else if (key === "e") { document.getElementById("tool-eraser")?.click(); }
|
||||
else if (key === "/") { document.getElementById("tool-comment")?.click(); }
|
||||
});
|
||||
|
||||
// Create a shape, add to canvas, and register for sync.
|
||||
|
|
@ -5216,6 +5231,23 @@
|
|||
setWbTool("eraser");
|
||||
});
|
||||
|
||||
// ── Comment tool ──
|
||||
document.getElementById("tool-comment").addEventListener("click", () => {
|
||||
if (pendingTool) clearPendingTool();
|
||||
setWbTool(null);
|
||||
if (pinManager.placementMode) {
|
||||
pinManager.exitPinPlacementMode();
|
||||
document.getElementById("tool-comment").classList.remove("active");
|
||||
} else {
|
||||
pinManager.enterPinPlacementMode();
|
||||
document.getElementById("tool-comment").classList.add("active");
|
||||
// Deactivate other tools
|
||||
document.querySelectorAll(".tool-btn").forEach(b => {
|
||||
if (b.id !== "tool-comment") b.classList.remove("active");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Arrow tool with style submenu ──
|
||||
let arrowStyle = localStorage.getItem("rspace_arrow_style") || "smooth";
|
||||
const arrowBtn = document.getElementById("tool-arrow");
|
||||
|
|
@ -5561,6 +5593,20 @@
|
|||
}
|
||||
});
|
||||
|
||||
// Comment pin placement — capturing phase intercepts before normal interaction
|
||||
canvas.addEventListener("pointerdown", (e) => {
|
||||
if (!pinManager.placementMode) return;
|
||||
// Ignore clicks on the popover or toolbar
|
||||
if (e.target.closest("#comment-pin-popover, #canvas-toolbar")) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const worldX = (e.clientX - rect.left - panX) / scale;
|
||||
const worldY = (e.clientY - rect.top - panY) / scale;
|
||||
pinManager.handleCanvasClick(worldX, worldY);
|
||||
document.getElementById("tool-comment").classList.remove("active");
|
||||
}, true);
|
||||
|
||||
// Eraser for any folk-shape — capturing phase intercepts
|
||||
// before normal shape interaction (drag/select) kicks in
|
||||
canvasContent.addEventListener("pointerdown", (e) => {
|
||||
|
|
@ -5703,6 +5749,7 @@
|
|||
html += `<div class="submenu" id="copy-space-submenu"><div class="submenu-loading">Loading spaces...</div></div>`;
|
||||
html += `</div>`;
|
||||
}
|
||||
html += `<button data-action="add-comment">💬 Add comment</button>`;
|
||||
html += `<button data-action="schedule-reminder">📅 Schedule a reminder</button>`;
|
||||
} else if (state === 'forgotten') {
|
||||
html += `<button data-action="remember">Remember</button>`;
|
||||
|
|
@ -5760,6 +5807,13 @@
|
|||
}
|
||||
|
||||
|
||||
if (action === 'add-comment') {
|
||||
pinManager.createPinOnShape(contextTargetIds[0]);
|
||||
shapeContextMenu.classList.remove("open");
|
||||
contextTargetIds = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'schedule-reminder') {
|
||||
const targetId = contextTargetIds[0];
|
||||
const el = document.getElementById(targetId);
|
||||
|
|
@ -6209,6 +6263,15 @@
|
|||
if (memoryPanel.classList.contains("open")) renderMemoryPanel();
|
||||
});
|
||||
|
||||
// Navigate to comment pin position
|
||||
canvas.addEventListener("comment-pin-navigate", (e) => {
|
||||
const { x, y } = e.detail;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const targetPanX = rect.width / 2 - x * scale;
|
||||
const targetPanY = rect.height / 2 - y * scale;
|
||||
animateZoom(scale, targetPanX, targetPanY, 400);
|
||||
});
|
||||
|
||||
function updateCanvasTransform() {
|
||||
// Transform only the content layer — canvas viewport stays fixed
|
||||
canvasContent.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
|
||||
|
|
@ -6229,6 +6292,7 @@
|
|||
}
|
||||
// Update remote cursors to match new camera position
|
||||
presence.setCamera(panX, panY, scale);
|
||||
pinManager.setCamera(panX, panY, scale);
|
||||
updateScheduleIcon();
|
||||
updateLockIcon();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue