feat(markdown): add MarkdownOverlay DOM overlay manager
This commit is contained in:
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* MarkdownOverlay — manages DOM div elements positioned over the PixiJS canvas
|
||||||
|
* for each visible markdown card.
|
||||||
|
*
|
||||||
|
* Creates a container div for each markdown item, positioned via viewport.toScreen().
|
||||||
|
* React components (MarkdownReadView / MarkdownEditView) render inside these containers.
|
||||||
|
* Handles culling (off-screen cards removed), zoom scaling, and position sync for
|
||||||
|
* drag/resize/remote transforms.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Viewport } from 'pixi-viewport';
|
||||||
|
import type { SceneManager, SceneItem } from './SceneManager';
|
||||||
|
import type { MarkdownObject } from './scene-format';
|
||||||
|
import { MarkdownSprite } from './sprites/MarkdownSprite';
|
||||||
|
import { extractTitle, MD_MAX_CONTENT_LENGTH } from './markdownDefaults';
|
||||||
|
|
||||||
|
interface CardEntry {
|
||||||
|
/** The root DOM div for this card, positioned absolutely over the canvas. */
|
||||||
|
el: HTMLDivElement;
|
||||||
|
/** The content mount point inside el where React renders read/edit views. */
|
||||||
|
contentMount: HTMLDivElement;
|
||||||
|
/** Current item ID. */
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MarkdownOverlay {
|
||||||
|
private _viewport: Viewport;
|
||||||
|
private _scene: SceneManager;
|
||||||
|
private _container: HTMLElement;
|
||||||
|
private _cards: Map<string, CardEntry> = new Map();
|
||||||
|
private _editingId: string | null = null;
|
||||||
|
|
||||||
|
/** Callback when a card's height changes (from DOM measurement). */
|
||||||
|
onHeightChange: ((id: string, newHeight: number) => void) | null = null;
|
||||||
|
|
||||||
|
/** Callback to enter edit mode for a card (wired by Editor.tsx). */
|
||||||
|
onRequestEdit: ((id: string) => void) | null = null;
|
||||||
|
|
||||||
|
/** Callback when checkbox is toggled in read mode. */
|
||||||
|
onCheckboxToggle: ((id: string, newContent: string) => void) | null = null;
|
||||||
|
|
||||||
|
/** Callback when visible card set changes (cards added/removed). Used by React to sync portals. */
|
||||||
|
onChange: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(viewport: Viewport, scene: SceneManager, container: HTMLElement) {
|
||||||
|
this._viewport = viewport;
|
||||||
|
this._scene = scene;
|
||||||
|
this._container = container;
|
||||||
|
|
||||||
|
this._onViewportMoved = this._onViewportMoved.bind(this);
|
||||||
|
viewport.on('moved', this._onViewportMoved);
|
||||||
|
viewport.on('zoomed', this._onViewportMoved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the DOM mount point for a card (used by React to portal into). */
|
||||||
|
getMountPoint(id: string): HTMLDivElement | null {
|
||||||
|
return this._cards.get(id)?.contentMount ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set which card is in edit mode (null = none). */
|
||||||
|
setEditing(id: string | null): void {
|
||||||
|
this._editingId = id;
|
||||||
|
// Update pointer-events: edit card gets auto, others get none
|
||||||
|
for (const [cardId, entry] of this._cards) {
|
||||||
|
entry.el.style.pointerEvents = cardId === id ? 'auto' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get editingId(): string | null {
|
||||||
|
return this._editingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full refresh — recreate all visible cards. Called on scene load, undo/redo. */
|
||||||
|
refreshAll(): void {
|
||||||
|
const items = this._scene.items;
|
||||||
|
const visibleIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const [id, item] of items) {
|
||||||
|
if (item.type !== 'markdown') continue;
|
||||||
|
if (!item.data.visible) continue;
|
||||||
|
|
||||||
|
// Culling: skip items outside viewport bounds
|
||||||
|
if (!this._isVisible(item)) continue;
|
||||||
|
|
||||||
|
visibleIds.add(id);
|
||||||
|
|
||||||
|
if (!this._cards.has(id)) {
|
||||||
|
this._createCard(item);
|
||||||
|
}
|
||||||
|
this._positionCard(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove cards that are no longer visible
|
||||||
|
for (const [id] of this._cards) {
|
||||||
|
if (!visibleIds.has(id)) {
|
||||||
|
this._removeCard(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a single card's position (for drag, resize, remote transform). */
|
||||||
|
updateItem(id: string): void {
|
||||||
|
const item = this._scene.getById(id);
|
||||||
|
if (!item || item.type !== 'markdown') return;
|
||||||
|
|
||||||
|
if (!this._isVisible(item)) {
|
||||||
|
this._removeCard(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._cards.has(id)) {
|
||||||
|
this._createCard(item);
|
||||||
|
}
|
||||||
|
this._positionCard(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a card's overlay (item deleted). */
|
||||||
|
removeItem(id: string): void {
|
||||||
|
this._removeCard(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Measure a card's DOM height and update data.h + sprite. */
|
||||||
|
measureHeight(id: string): void {
|
||||||
|
const entry = this._cards.get(id);
|
||||||
|
const item = this._scene.getById(id);
|
||||||
|
if (!entry || !item || item.type !== 'markdown') return;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const h = entry.el.offsetHeight / this._viewport.scale.x;
|
||||||
|
if (Math.abs(h - item.data.h) > 1) {
|
||||||
|
item.data.h = h;
|
||||||
|
// Redraw MarkdownSprite background
|
||||||
|
if (item.displayObject instanceof MarkdownSprite) {
|
||||||
|
item.displayObject.updateFromData(item.data as MarkdownObject);
|
||||||
|
}
|
||||||
|
this.onHeightChange?.(id, h);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this._viewport.off('moved', this._onViewportMoved);
|
||||||
|
this._viewport.off('zoomed', this._onViewportMoved);
|
||||||
|
for (const [id] of this._cards) {
|
||||||
|
this._removeCard(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Private --
|
||||||
|
|
||||||
|
private _onViewportMoved(): void {
|
||||||
|
this.refreshAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _isVisible(item: SceneItem): boolean {
|
||||||
|
// pixi-viewport exposes bounds via .left/.right/.top/.bottom
|
||||||
|
const vp = this._viewport;
|
||||||
|
const d = item.data;
|
||||||
|
return !(d.x + d.w < vp.left || d.x > vp.right || d.y + d.h < vp.top || d.y > vp.bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _createCard(item: SceneItem): void {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.style.position = 'absolute';
|
||||||
|
el.style.pointerEvents = item.id === this._editingId ? 'auto' : 'none';
|
||||||
|
el.style.transformOrigin = 'top left';
|
||||||
|
el.style.overflow = 'hidden';
|
||||||
|
el.dataset.markdownId = item.id;
|
||||||
|
|
||||||
|
const contentMount = document.createElement('div');
|
||||||
|
el.appendChild(contentMount);
|
||||||
|
|
||||||
|
this._container.appendChild(el);
|
||||||
|
this._cards.set(item.id, { el, contentMount, id: item.id });
|
||||||
|
this.onChange?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _positionCard(id: string): void {
|
||||||
|
const entry = this._cards.get(id);
|
||||||
|
const item = this._scene.getById(id);
|
||||||
|
if (!entry || !item) return;
|
||||||
|
|
||||||
|
const data = item.data as MarkdownObject;
|
||||||
|
const zoom = this._viewport.scale.x;
|
||||||
|
const screen = this._viewport.toScreen(data.x, data.y);
|
||||||
|
|
||||||
|
entry.el.style.left = `${screen.x}px`;
|
||||||
|
entry.el.style.top = `${screen.y}px`;
|
||||||
|
entry.el.style.width = `${data.w}px`;
|
||||||
|
entry.el.style.transform = `scale(${zoom})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _removeCard(id: string): void {
|
||||||
|
const entry = this._cards.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.el.remove();
|
||||||
|
this._cards.delete(id);
|
||||||
|
this.onChange?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user