Merge branch 'dev'
This commit is contained in:
commit
0ca41b1734
|
|
@ -1,9 +1,10 @@
|
|||
---
|
||||
id: TASK-39
|
||||
title: Port MycelialIntelligence system (global AI bar + shape)
|
||||
status: To Do
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-18 19:51'
|
||||
updated_date: '2026-02-28 00:29'
|
||||
labels:
|
||||
- shape-port
|
||||
- phase-5
|
||||
|
|
@ -42,9 +43,43 @@ The bar should be added as a persistent element in canvas.html, independent of t
|
|||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 AI bar renders as persistent bottom UI element
|
||||
- [ ] #2 Chat prompt sends to LLM and displays responses
|
||||
- [ ] #3 Bar is context-aware of selected shapes and canvas state
|
||||
- [ ] #4 Can create/modify shapes via AI commands
|
||||
- [x] #2 Chat prompt sends to LLM and displays responses
|
||||
- [x] #3 Bar is context-aware of selected shapes and canvas state
|
||||
- [x] #4 Can create/modify shapes via AI commands
|
||||
- [ ] #5 Backward-compat folk-mycelial-intelligence shape exists
|
||||
- [ ] #6 Toolbar button toggles bar visibility
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Phase A partially complete via rstack-mi header bar (shared/components/rstack-mi.ts). Context-aware MI bar now gathers open shapes, active tab, page title and sends to /api/mi/ask. Present in header across all *.rspace.online pages. Commits: f8bd09d, 59f2be3. Remaining: Phase B-D (deeper canvas integration, shape creation via AI, full tool integration) still To Do.
|
||||
|
||||
Phase B-D implemented (2026-02-27):
|
||||
|
||||
**Phase B — Deep Canvas Context:**
|
||||
- `lib/mi-canvas-bridge.ts`: Singleton collecting shapes, connections, viewport, shape groups from live canvas
|
||||
- `website/canvas.html`: Wired bridge to selection (pointerdown), viewport (pan/zoom), deselection
|
||||
- `rstack-mi.ts`: `#gatherContext()` reads bridge for selected shapes, connections, viewport, shape groups, type stats
|
||||
- `server/index.ts`: System prompt includes selected shapes, connections, viewport zoom/pan, shape groups, type counts
|
||||
|
||||
**Phase C — Shape Creation/Modification:**
|
||||
- `lib/mi-actions.ts`: `[MI_ACTION:{...}]` parser + action types (create/update/delete/connect/move/transform/navigate)
|
||||
- `lib/mi-action-executor.ts`: Executes actions against canvas via `window.__canvasApi` with `$N` backreferences
|
||||
- `website/canvas.html`: Exposes `window.__canvasApi` (newShape, findFreePosition, SHAPE_DEFAULTS, sync)
|
||||
- `rstack-mi.ts`: Parses actions from response, executes them, shows confirmation chips
|
||||
- `server/index.ts`: Action/transform syntax documentation in system prompt
|
||||
|
||||
**Phase D — Transforms + Tool Suggestions:**
|
||||
- `lib/mi-selection-transforms.ts`: 15 transforms (align, distribute, arrange, match-size) on `window.__miSelectionTransforms`
|
||||
- `lib/mi-tool-schema.ts`: 23 tool hints with keyword scoring, `suggestTools()` returns top 3 matches
|
||||
- `rstack-mi.ts`: Tool suggestion chips below responses, clickable to create shapes
|
||||
|
||||
All exported from `lib/index.ts`. Commits: d850a76, 0c00a69.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
MI Phases A-D complete. The MI bar is context-aware of the full canvas state (selection, connections, viewport, shape groups), can create/modify/delete/connect shapes via `[MI_ACTION:{...}]` protocol with $N backreferences, supports 15 selection transforms (align, distribute, arrange, match-size), and suggests relevant tools as clickable chips. 5 new lib files, 4 modified files, ~900 lines added. Deployed to production.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
---
|
||||
id: TASK-65
|
||||
title: Feed-aware flow wiring + CSS 3D interactive layer view
|
||||
status: Done
|
||||
assignee:
|
||||
- '@claude'
|
||||
created_date: '2026-02-27 22:46'
|
||||
updated_date: '2026-02-28 00:29'
|
||||
labels:
|
||||
- feature
|
||||
- canvas
|
||||
- ux
|
||||
milestone: rspace-app-ecosystem
|
||||
dependencies:
|
||||
- TASK-57
|
||||
references:
|
||||
- shared/components/rstack-tab-bar.ts
|
||||
- shared/module.ts
|
||||
- lib/layer-types.ts
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Extend the layered tab system (TASK-57) with feed-aware flow wiring and a CSS 3D interactive layer view replacing the flat SVG stack view.
|
||||
|
||||
## Feed-Aware Flow Wiring
|
||||
- Extended TabBarModule interface with feeds/acceptsFeeds fields
|
||||
- Added compatibility helpers: #getModuleOutputKinds, #getModuleInputKinds, #getCompatibleKinds, #getContainedFeeds
|
||||
- Flow creation dialog now filters kind buttons by source/target feed compatibility
|
||||
- Disabled incompatible kinds at 25% opacity with pointer-events none
|
||||
- Feed count badges on compatible kinds
|
||||
- Default selection = first compatible kind (not always "data")
|
||||
|
||||
## CSS 3D Interactive Layer View
|
||||
- Replaced SVG #renderStackView() with CSS perspective + preserve-3d scene
|
||||
- Glassmorphism layer planes with backdrop-filter blur, module badge colors
|
||||
- Feed port indicators (colored dots for in/out kinds on each layer)
|
||||
- Containment indicators — lock icon on feeds with no outgoing flow
|
||||
- Animated flow particles via CSS keyframes, count proportional to strength
|
||||
- Orbit controls — mouse drag rotates scene (rotateX/rotateZ)
|
||||
- Scroll zoom — adjusts perspective distance
|
||||
- Time scrubber — play/pause button + speed slider (0.1x–5x)
|
||||
- Click layer to switch, drag between layers to create flow, right-click particle to delete flow
|
||||
- Responsive mobile sizing
|
||||
|
||||
## FeedDefinition consolidation
|
||||
- Fixed shared/module.ts to import FeedDefinition locally (was only re-exporting)
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Tab bar extended with feeds/acceptsFeeds on TabBarModule interface
|
||||
- [x] #2 Flow dialog filters kind buttons by source feeds / target acceptsFeeds compatibility
|
||||
- [x] #3 Disabled flow kinds shown at 25% opacity with pointer-events none
|
||||
- [x] #4 Feed count badge shown on compatible flow kinds
|
||||
- [x] #5 Default flow kind selection is first compatible (not always data)
|
||||
- [x] #6 3D scene renders layers as translucent CSS planes with perspective
|
||||
- [x] #7 Mouse drag on empty space orbits/rotates the 3D scene
|
||||
- [x] #8 Scroll wheel zooms (adjusts perspective distance)
|
||||
- [x] #9 Flow particles animate between layer planes with CSS keyframes
|
||||
- [x] #10 Particle count proportional to flow strength, color matches FLOW_COLORS
|
||||
- [x] #11 Time scrubber controls particle speed (0.1x–5x) with play/pause
|
||||
- [x] #12 Contained feeds (no outgoing flow) show lock icon on layer plane
|
||||
- [x] #13 Click layer to switch tab, drag between layers opens flow dialog
|
||||
- [x] #14 Right-click flow particle to delete flow
|
||||
- [x] #15 bunx tsc --noEmit passes with zero errors
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
I/O chip upgrade (2026-02-27): Replaced 6px dot ports with labeled pluggable I/O chips on each layer plane. Output feeds shown as filled chips with feed names (e.g. "Treasury Flows"), input accepts shown as dashed-outline chips by flow kind (e.g. "Data", "Delegation"). Contained feeds dimmed with lock icon inline. Flow colors updated: green=economic (#4ade80), purple=delegation (#a78bfa), blue=data (#60a5fa). Governance label renamed to "Delegation". Commits: d850a76, 0c00a69.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
## Summary
|
||||
Extended the layered tab system with feed-aware flow wiring and a CSS 3D interactive layer view.
|
||||
|
||||
## Files Changed (2 files, +246/-14 lines)
|
||||
|
||||
**Modified files:**
|
||||
- `shared/components/rstack-tab-bar.ts` (+246 lines) — Extended TabBarModule with feeds/acceptsFeeds, 4 compatibility helper methods, feed-aware flow dialog with filtering/badges/smart defaults, replaced SVG stack view with CSS 3D perspective scene (glassmorphism layers, animated flow particles, orbit controls, scroll zoom, time scrubber, containment indicators, responsive mobile)
|
||||
- `shared/module.ts` (+1/-1 line) — Import FeedDefinition locally alongside re-export
|
||||
|
||||
## Commits
|
||||
- `9e4648b` feat: feed-aware flow wiring + CSS 3D interactive layer view (dev)
|
||||
- `2ef68e7` merge dev→main
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
id: TASK-69
|
||||
title: folk-rapp auto-derives space context + subdomain URL canonicalization
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-28 00:29'
|
||||
updated_date: '2026-02-28 00:29'
|
||||
labels:
|
||||
- fix
|
||||
- routing
|
||||
- canvas
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Two fixes ensuring rApps on canvas load as applets directly in the correct space:
|
||||
|
||||
1. **folk-rapp race condition fix**: `createRenderRoot` unconditionally read `getAttribute("module-id")` which returned `""` and overwrote the `moduleId` already set via JS property setter — showing the picker menu instead of loading the module. Fixed to preserve JS-set properties.
|
||||
|
||||
2. **Auto-derive spaceSlug**: folk-rapp now reads the current URL path (`/{space}/canvas` → space) to auto-derive spaceSlug so embedded rApps always know their space context without explicit passing.
|
||||
|
||||
3. **Subdomain canonicalization**: `rspace.online/{space}/{moduleId}` now 301-redirects to `{space}.rspace.online/{moduleId}`. Spaces are subdomains, not path segments.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 folk-rapp shapes with moduleId set load the iframe directly without showing picker
|
||||
- [x] #2 spaceSlug auto-derived from URL when not explicitly provided
|
||||
- [x] #3 rspace.online/{space}/{moduleId} redirects 301 to {space}.rspace.online/{moduleId}
|
||||
- [x] #4 newShapeElement passes communitySlug as fallback spaceSlug when restoring folk-rapp from sync
|
||||
- [x] #5 bun run build passes with zero errors
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Fixed folk-rapp shapes to load as applets directly in the current space. Resolved race condition where createRenderRoot overwrote JS-set moduleId. Added auto-derivation of spaceSlug from URL. Added 301 redirect from path-based space URLs to subdomain form. Commits: 09d23f8, 9f3c9ab. Deployed to production.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -25,6 +25,8 @@ export interface ShapeData {
|
|||
isMinimized?: boolean;
|
||||
isPinned?: boolean;
|
||||
tags?: string[];
|
||||
// Whiteboard SVG drawing
|
||||
svgMarkup?: string;
|
||||
// Allow arbitrary shape-specific properties from toJSON()
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
|
@ -495,6 +497,18 @@ export class CommunitySync extends EventTarget {
|
|||
this.forgetShape(shapeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add raw shape data directly (for shapes without DOM elements, like wb-svg drawings).
|
||||
*/
|
||||
addShapeData(shapeData: ShapeData): void {
|
||||
this.#doc = Automerge.change(this.#doc, `Add shape ${shapeData.id}`, (doc) => {
|
||||
if (!doc.shapes) doc.shapes = {};
|
||||
doc.shapes[shapeData.id] = shapeData;
|
||||
});
|
||||
this.#scheduleSave();
|
||||
this.#syncToServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* FUN: Update — explicitly update specific fields of a shape.
|
||||
* Use this for programmatic updates (API calls, module callbacks).
|
||||
|
|
|
|||
|
|
@ -186,23 +186,51 @@ export class FolkMarkdown extends FolkShape {
|
|||
const editBtn = wrapper.querySelector(".edit-btn") as HTMLButtonElement;
|
||||
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
|
||||
|
||||
// Edit toggle
|
||||
// Helper to enter/exit markdown edit mode
|
||||
const enterMarkdownEdit = () => {
|
||||
if (this.#isEditing) return;
|
||||
this.#isEditing = true;
|
||||
editor.style.display = "block";
|
||||
preview.style.display = "none";
|
||||
editor.value = this.#content;
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
const exitMarkdownEdit = () => {
|
||||
if (!this.#isEditing) return;
|
||||
this.#isEditing = false;
|
||||
editor.style.display = "none";
|
||||
preview.style.display = "block";
|
||||
this.content = editor.value;
|
||||
preview.innerHTML = this.#renderMarkdown(this.#content);
|
||||
};
|
||||
|
||||
// Edit toggle button
|
||||
editBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#isEditing = !this.#isEditing;
|
||||
if (this.#isEditing) {
|
||||
editor.style.display = "block";
|
||||
preview.style.display = "none";
|
||||
editor.value = this.#content;
|
||||
editor.focus();
|
||||
exitMarkdownEdit();
|
||||
} else {
|
||||
editor.style.display = "none";
|
||||
preview.style.display = "block";
|
||||
this.content = editor.value;
|
||||
preview.innerHTML = this.#renderMarkdown(this.#content);
|
||||
enterMarkdownEdit();
|
||||
}
|
||||
});
|
||||
|
||||
// Click on preview enters edit mode (when shape is focused/editing)
|
||||
preview.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
enterMarkdownEdit();
|
||||
});
|
||||
|
||||
// When parent shape enters edit mode, also enter markdown edit
|
||||
this.addEventListener("edit-enter", () => {
|
||||
enterMarkdownEdit();
|
||||
});
|
||||
|
||||
// When parent shape exits edit mode, also exit markdown edit
|
||||
this.addEventListener("edit-exit", () => {
|
||||
exitMarkdownEdit();
|
||||
});
|
||||
|
||||
// Close button
|
||||
closeBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -215,11 +243,7 @@ export class FolkMarkdown extends FolkShape {
|
|||
});
|
||||
|
||||
editor.addEventListener("blur", () => {
|
||||
this.#isEditing = false;
|
||||
editor.style.display = "none";
|
||||
preview.style.display = "block";
|
||||
this.content = editor.value;
|
||||
preview.innerHTML = this.#renderMarkdown(this.#content);
|
||||
exitMarkdownEdit();
|
||||
});
|
||||
|
||||
// Initial render
|
||||
|
|
|
|||
|
|
@ -97,11 +97,15 @@ const styles = css`
|
|||
outline: none;
|
||||
}
|
||||
|
||||
:host(:hover),
|
||||
:host(:state(highlighted)) {
|
||||
:host(:hover) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:host(:state(highlighted)) {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
:host(:state(move)),
|
||||
:host(:state(rotate)),
|
||||
:host(:state(resize-top-left)),
|
||||
|
|
|
|||
|
|
@ -442,6 +442,16 @@
|
|||
overflow: visible;
|
||||
}
|
||||
|
||||
#select-rect {
|
||||
position: fixed;
|
||||
border: 1.5px solid #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 9998;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Touch-friendly resize handles */
|
||||
@media (pointer: coarse) {
|
||||
folk-shape::part(resize-top-left),
|
||||
|
|
@ -840,6 +850,7 @@
|
|||
</div>
|
||||
|
||||
<div id="canvas"><div id="canvas-content"></div></div>
|
||||
<div id="select-rect"></div>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
|
@ -1168,7 +1179,35 @@
|
|||
const storedUsername = localStorage.getItem("rspace-username") || `User ${peerId.slice(0, 4)}`;
|
||||
const presence = new PresenceManager(canvas, peerId, storedUsername);
|
||||
|
||||
// Track selected shape for presence sharing
|
||||
// Track selected shapes for presence sharing and multi-select
|
||||
let selectedShapeIds = new Set();
|
||||
|
||||
function selectShape(id, additive = false) {
|
||||
if (!additive) selectedShapeIds.clear();
|
||||
selectedShapeIds.add(id);
|
||||
updateSelectionVisuals();
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
selectedShapeIds.clear();
|
||||
updateSelectionVisuals();
|
||||
}
|
||||
|
||||
function updateSelectionVisuals() {
|
||||
for (const el of canvasContent.children) {
|
||||
if (el.highlighted !== undefined) {
|
||||
el.highlighted = selectedShapeIds.has(el.id);
|
||||
}
|
||||
}
|
||||
__miCanvasBridge.setSelection([...selectedShapeIds]);
|
||||
}
|
||||
|
||||
function rectsOverlapScreen(sel, r) {
|
||||
return !(sel.left > r.right || sel.right < r.left ||
|
||||
sel.top > r.bottom || sel.bottom < r.top);
|
||||
}
|
||||
|
||||
// Compat alias for presence (uses first selected)
|
||||
let selectedShapeId = null;
|
||||
|
||||
// Throttle cursor updates (send at most every 50ms)
|
||||
|
|
@ -1240,6 +1279,10 @@
|
|||
if (document.getElementById(data.id)) {
|
||||
return;
|
||||
}
|
||||
// Check if wb-svg already exists in the overlay
|
||||
if (data.type === "wb-svg" && wbOverlay.querySelector(`[data-wb-id="${data.id}"]`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isProcessingRemote = true;
|
||||
|
|
@ -1262,6 +1305,9 @@
|
|||
if (shape && shape.parentNode) {
|
||||
shape.remove();
|
||||
}
|
||||
// Also remove wb-svg elements from the overlay
|
||||
const wbEl = wbOverlay?.querySelector(`[data-wb-id="${shapeId}"]`);
|
||||
if (wbEl) wbEl.remove();
|
||||
});
|
||||
|
||||
// Create a shape element from data
|
||||
|
|
@ -1487,6 +1533,18 @@
|
|||
if (data.maxItems) shape.maxItems = data.maxItems;
|
||||
if (data.refreshInterval) shape.refreshInterval = data.refreshInterval;
|
||||
break;
|
||||
case "wb-svg":
|
||||
// Whiteboard SVG drawing — recreate in SVG overlay, not as a folk-shape
|
||||
if (data.svgMarkup) {
|
||||
const temp = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
||||
temp.innerHTML = data.svgMarkup;
|
||||
const svgEl = temp.firstElementChild;
|
||||
if (svgEl) {
|
||||
svgEl.setAttribute("data-wb-id", data.id);
|
||||
wbOverlay.appendChild(svgEl);
|
||||
}
|
||||
}
|
||||
return null; // Not a folk-shape element
|
||||
case "folk-markdown":
|
||||
default:
|
||||
shape = document.createElement("folk-markdown");
|
||||
|
|
@ -1520,10 +1578,21 @@
|
|||
}
|
||||
});
|
||||
|
||||
// Track selection for MI bridge
|
||||
shape.addEventListener("pointerdown", () => {
|
||||
// Track selection for MI bridge — supports Shift/Ctrl+click multi-select
|
||||
shape.addEventListener("pointerdown", (e) => {
|
||||
if (e.shiftKey || e.metaKey || e.ctrlKey) {
|
||||
// Additive toggle
|
||||
if (selectedShapeIds.has(shape.id)) {
|
||||
selectedShapeIds.delete(shape.id);
|
||||
} else {
|
||||
selectedShapeIds.add(shape.id);
|
||||
}
|
||||
} else if (!selectedShapeIds.has(shape.id)) {
|
||||
selectedShapeIds.clear();
|
||||
selectedShapeIds.add(shape.id);
|
||||
}
|
||||
selectedShapeId = shape.id;
|
||||
__miCanvasBridge.setSelection([shape.id]);
|
||||
updateSelectionVisuals();
|
||||
});
|
||||
|
||||
// Close button
|
||||
|
|
@ -2170,15 +2239,34 @@
|
|||
wbOverlay.addEventListener("pointerup", (e) => {
|
||||
if (!wbDrawing) return;
|
||||
wbDrawing = false;
|
||||
|
||||
// Persist the completed SVG element to Automerge
|
||||
if (wbPreviewEl) {
|
||||
const wbId = `wb-${Date.now()}-${++shapeCounter}`;
|
||||
wbPreviewEl.setAttribute("data-wb-id", wbId);
|
||||
const svgMarkup = wbPreviewEl.outerHTML;
|
||||
|
||||
sync.addShapeData({
|
||||
type: "wb-svg",
|
||||
id: wbId,
|
||||
svgMarkup,
|
||||
x: 0, y: 0, width: 0, height: 0, rotation: 0,
|
||||
});
|
||||
}
|
||||
|
||||
wbPreviewEl = null;
|
||||
wbCurrentPath = [];
|
||||
});
|
||||
|
||||
// Eraser: click on existing SVG strokes to delete them
|
||||
// Eraser: click on existing SVG strokes to delete them + remove from Automerge
|
||||
wbOverlay.addEventListener("click", (e) => {
|
||||
if (wbTool !== "eraser") return;
|
||||
const hit = e.target;
|
||||
if (hit && hit !== wbOverlay && wbOverlay.contains(hit)) {
|
||||
const wbId = hit.getAttribute("data-wb-id");
|
||||
if (wbId) {
|
||||
sync.deleteShape(wbId);
|
||||
}
|
||||
hit.remove();
|
||||
}
|
||||
});
|
||||
|
|
@ -2407,6 +2495,21 @@
|
|||
}
|
||||
});
|
||||
|
||||
// Desktop quick-add button → opens the rApps popout panel
|
||||
document.getElementById("quick-add")?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const rAppsGroup = toolbarEl.querySelector(".toolbar-group:has(#embed-notes)")
|
||||
|| [...toolbarEl.querySelectorAll(".toolbar-group")].find(g =>
|
||||
g.querySelector(".toolbar-group-toggle")?.textContent.includes("rApps"));
|
||||
if (rAppsGroup) {
|
||||
if (activeToolbarGroup === rAppsGroup) {
|
||||
closeToolbarPanel();
|
||||
} else {
|
||||
openToolbarPanel(rAppsGroup);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Collapse/expand toolbar
|
||||
const collapseBtn = document.getElementById("toolbar-collapse");
|
||||
collapseBtn.addEventListener("click", () => {
|
||||
|
|
@ -2489,11 +2592,43 @@
|
|||
updateCanvasTransform();
|
||||
}, { passive: false });
|
||||
|
||||
// Single-finger canvas pan (pointer events on empty background)
|
||||
// ── Space key tracking for space+drag pan ──
|
||||
let spaceHeld = false;
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.code === "Space" && !e.target.closest("input, textarea, [contenteditable]")) {
|
||||
e.preventDefault();
|
||||
spaceHeld = true;
|
||||
canvas.style.cursor = "grab";
|
||||
}
|
||||
});
|
||||
document.addEventListener("keyup", (e) => {
|
||||
if (e.code === "Space") {
|
||||
spaceHeld = false;
|
||||
if (!isPanning) canvas.style.cursor = "";
|
||||
}
|
||||
});
|
||||
|
||||
// ── Delete selected shapes ──
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if ((e.key === "Delete" || e.key === "Backspace") &&
|
||||
!e.target.closest("input, textarea, [contenteditable]") &&
|
||||
selectedShapeIds.size > 0) {
|
||||
for (const id of selectedShapeIds) {
|
||||
sync.deleteShape(id);
|
||||
document.getElementById(id)?.remove();
|
||||
}
|
||||
deselectAll();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Canvas pointer interaction: marquee selection + pan ──
|
||||
let isPanning = false;
|
||||
let panPointerId = null;
|
||||
let panStartX = 0;
|
||||
let panStartY = 0;
|
||||
let isSelecting = false;
|
||||
let selectStartX = 0, selectStartY = 0;
|
||||
const selectRect = document.getElementById("select-rect");
|
||||
|
||||
canvas.addEventListener("pointerdown", (e) => {
|
||||
if (e.target !== canvas && e.target !== canvasContent) return;
|
||||
|
|
@ -2516,44 +2651,112 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Clicking canvas background clears MI selection and exits any editing shape
|
||||
// Whiteboard tool active → don't select or pan
|
||||
if (wbTool) return;
|
||||
|
||||
// Middle-click or Space held → PAN
|
||||
if (e.button === 1 || spaceHeld) {
|
||||
isPanning = true;
|
||||
panPointerId = e.pointerId;
|
||||
panStartX = e.clientX;
|
||||
panStartY = e.clientY;
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
canvas.style.cursor = "grabbing";
|
||||
return;
|
||||
}
|
||||
|
||||
// Left-click on background → start marquee selection
|
||||
deselectAll();
|
||||
selectedShapeId = null;
|
||||
__miCanvasBridge.setSelection([]);
|
||||
// Exit edit mode on any currently-editing shape
|
||||
canvasContent.querySelectorAll("folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-obs-note, folk-rapp, folk-embed, folk-drawfast, folk-prompt, folk-workflow-block").forEach(el => {
|
||||
if (el.exitEditMode) el.exitEditMode();
|
||||
});
|
||||
isPanning = true;
|
||||
panPointerId = e.pointerId;
|
||||
panStartX = e.clientX;
|
||||
panStartY = e.clientY;
|
||||
|
||||
isSelecting = true;
|
||||
selectStartX = e.clientX;
|
||||
selectStartY = e.clientY;
|
||||
selectRect.style.display = "block";
|
||||
selectRect.style.left = e.clientX + "px";
|
||||
selectRect.style.top = e.clientY + "px";
|
||||
selectRect.style.width = "0";
|
||||
selectRect.style.height = "0";
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
canvas.style.cursor = "grabbing";
|
||||
});
|
||||
|
||||
canvas.addEventListener("pointermove", (e) => {
|
||||
if (!isPanning || e.pointerId !== panPointerId) return;
|
||||
const dx = e.clientX - panStartX;
|
||||
const dy = e.clientY - panStartY;
|
||||
panX += dx;
|
||||
panY += dy;
|
||||
panStartX = e.clientX;
|
||||
panStartY = e.clientY;
|
||||
updateCanvasTransform();
|
||||
if (isPanning && e.pointerId === panPointerId) {
|
||||
const dx = e.clientX - panStartX;
|
||||
const dy = e.clientY - panStartY;
|
||||
panX += dx;
|
||||
panY += dy;
|
||||
panStartX = e.clientX;
|
||||
panStartY = e.clientY;
|
||||
updateCanvasTransform();
|
||||
return;
|
||||
}
|
||||
if (!isSelecting) return;
|
||||
|
||||
const x = Math.min(selectStartX, e.clientX);
|
||||
const y = Math.min(selectStartY, e.clientY);
|
||||
const w = Math.abs(e.clientX - selectStartX);
|
||||
const h = Math.abs(e.clientY - selectStartY);
|
||||
selectRect.style.left = x + "px";
|
||||
selectRect.style.top = y + "px";
|
||||
selectRect.style.width = w + "px";
|
||||
selectRect.style.height = h + "px";
|
||||
});
|
||||
|
||||
canvas.addEventListener("pointerup", (e) => {
|
||||
if (e.pointerId !== panPointerId) return;
|
||||
isPanning = false;
|
||||
panPointerId = null;
|
||||
canvas.style.cursor = "";
|
||||
if (isPanning && e.pointerId === panPointerId) {
|
||||
isPanning = false;
|
||||
panPointerId = null;
|
||||
canvas.style.cursor = spaceHeld ? "grab" : "";
|
||||
return;
|
||||
}
|
||||
if (!isSelecting) return;
|
||||
isSelecting = false;
|
||||
selectRect.style.display = "none";
|
||||
|
||||
// Convert screen rect to find shapes inside
|
||||
const selRect = {
|
||||
left: Math.min(selectStartX, e.clientX),
|
||||
top: Math.min(selectStartY, e.clientY),
|
||||
right: Math.max(selectStartX, e.clientX),
|
||||
bottom: Math.max(selectStartY, e.clientY),
|
||||
};
|
||||
|
||||
// If tiny drag (< 4px), treat as a click → deselect all (already done)
|
||||
if (selRect.right - selRect.left < 4 && selRect.bottom - selRect.top < 4) return;
|
||||
|
||||
// Hit-test shapes against screen coordinates
|
||||
for (const el of canvasContent.children) {
|
||||
if (!el.id || typeof el.x !== "number") continue;
|
||||
const shapeScreenRect = el.getBoundingClientRect();
|
||||
if (rectsOverlapScreen(selRect, shapeScreenRect)) {
|
||||
selectedShapeIds.add(el.id);
|
||||
}
|
||||
}
|
||||
updateSelectionVisuals();
|
||||
});
|
||||
|
||||
canvas.addEventListener("pointercancel", (e) => {
|
||||
if (e.pointerId !== panPointerId) return;
|
||||
isPanning = false;
|
||||
panPointerId = null;
|
||||
canvas.style.cursor = "";
|
||||
if (isPanning && e.pointerId === panPointerId) {
|
||||
isPanning = false;
|
||||
panPointerId = null;
|
||||
canvas.style.cursor = "";
|
||||
}
|
||||
if (isSelecting) {
|
||||
isSelecting = false;
|
||||
selectRect.style.display = "none";
|
||||
}
|
||||
});
|
||||
|
||||
// Double-click on empty canvas background → quick-draw (pencil) mode
|
||||
canvas.addEventListener("dblclick", (e) => {
|
||||
if (e.target === canvas || e.target === canvasContent) {
|
||||
setWbTool("pencil");
|
||||
}
|
||||
});
|
||||
|
||||
// Keep-alive ping to prevent WebSocket idle timeout
|
||||
|
|
|
|||
Loading…
Reference in New Issue