snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
This commit is contained in:
843
blocksuite/affine/blocks/surface/src/renderer/dom-renderer.ts
Normal file
843
blocksuite/affine/blocks/surface/src/renderer/dom-renderer.ts
Normal file
@@ -0,0 +1,843 @@
|
||||
import {
|
||||
type Color,
|
||||
ColorScheme,
|
||||
type ShapeElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
||||
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import {
|
||||
type Bound,
|
||||
getBoundWithRotation,
|
||||
intersects,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import { type BlockStdScope, SurfaceSelection } from '@blocksuite/std';
|
||||
import type {
|
||||
GfxCompatibleInterface,
|
||||
GridManager,
|
||||
LayerManager,
|
||||
SurfaceBlockModel,
|
||||
Viewport,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { viewportRuntimeConfig } from '@blocksuite/std/gfx';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import type { SurfaceElementModel } from '../element-model/base.js';
|
||||
import type { DomElementRenderer } from './dom-elements/index.js';
|
||||
import { DomElementRendererIdentifier } from './dom-elements/index.js';
|
||||
import type { Overlay } from './overlay.js';
|
||||
import { resolveSurfacePlaceholderColor } from './placeholder-style.js';
|
||||
|
||||
type EnvProvider = {
|
||||
generateColorProperty: (color: Color, fallback?: Color) => string;
|
||||
getColorScheme: () => ColorScheme;
|
||||
getColorValue: (color: Color, fallback?: Color, real?: boolean) => string;
|
||||
getPropertyValue: (property: string) => string;
|
||||
selectedElements?: () => string[];
|
||||
};
|
||||
|
||||
type RendererOptions = {
|
||||
std: BlockStdScope;
|
||||
viewport: Viewport;
|
||||
layerManager: LayerManager;
|
||||
provider?: Partial<EnvProvider>;
|
||||
gridManager: GridManager;
|
||||
surfaceModel: SurfaceBlockModel;
|
||||
};
|
||||
|
||||
const UpdateType = {
|
||||
ELEMENT_ADDED: 'element-added',
|
||||
ELEMENT_REMOVED: 'element-removed',
|
||||
ELEMENT_UPDATED: 'element-updated',
|
||||
VIEWPORT_CHANGED: 'viewport-changed',
|
||||
SIZE_CHANGED: 'size-changed',
|
||||
ZOOM_STATE_CHANGED: 'zoom-state-changed',
|
||||
} as const;
|
||||
|
||||
type UpdateType = (typeof UpdateType)[keyof typeof UpdateType];
|
||||
|
||||
interface IncrementalUpdateState {
|
||||
dirtyElementIds: Set<string>;
|
||||
viewportDirty: boolean;
|
||||
sizeDirty: boolean;
|
||||
usePlaceholderDirty: boolean;
|
||||
pendingUpdates: Map<string, UpdateType[]>;
|
||||
}
|
||||
|
||||
const PLACEHOLDER_RESET_STYLES = {
|
||||
border: 'none',
|
||||
borderRadius: '0',
|
||||
boxShadow: 'none',
|
||||
opacity: '1',
|
||||
};
|
||||
|
||||
function calculatePlaceholderRect(
|
||||
elementModel: SurfaceElementModel,
|
||||
viewportBounds: Bound,
|
||||
zoom: number
|
||||
) {
|
||||
return {
|
||||
transform: elementModel.rotate ? `rotate(${elementModel.rotate}deg)` : '',
|
||||
left: `${(elementModel.x - viewportBounds.x) * zoom}px`,
|
||||
top: `${(elementModel.y - viewportBounds.y) * zoom}px`,
|
||||
width: `${elementModel.w * zoom}px`,
|
||||
height: `${elementModel.h * zoom}px`,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateFullElementRect(
|
||||
elementModel: SurfaceElementModel,
|
||||
viewportBounds: Bound,
|
||||
zoom: number
|
||||
) {
|
||||
const dx = elementModel.x - viewportBounds.x;
|
||||
const dy = elementModel.y - viewportBounds.y;
|
||||
return {
|
||||
left: `${dx * zoom}px`,
|
||||
top: `${dy * zoom}px`,
|
||||
};
|
||||
}
|
||||
|
||||
function getOpacity(elementModel: SurfaceElementModel) {
|
||||
return { opacity: `${elementModel.opacity ?? 1}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* @class DomRenderer
|
||||
* Renders surface elements directly to the DOM using HTML elements and CSS.
|
||||
*
|
||||
* This renderer supports an extension mechanism to handle different types of surface elements.
|
||||
* To add rendering support for a new element type (e.g., 'my-custom-element'), follow these steps:
|
||||
*
|
||||
* 1. **Define the Renderer Function**:
|
||||
* Create a function that implements the rendering logic for your element.
|
||||
* This function will receive the element's model, the target HTMLElement, and the DomRenderer instance.
|
||||
* Signature: `(model: MyCustomElementModel, domElement: HTMLElement, renderer: DomRenderer) => void;`
|
||||
* Example: `shapeDomRenderer` in `blocksuite/affine/gfx/shape/src/element-renderer/shape-dom/index.ts`.
|
||||
* In this function, you'll apply styles and attributes to the `domElement` based on the `model`.
|
||||
*
|
||||
* 2. **Create the Renderer Extension**:
|
||||
* Create a new file (e.g., `my-custom-element-dom-renderer.extension.ts`).
|
||||
* Import `DomElementRendererExtension` (e.g., from `@blocksuite/affine-block-surface` or its source location
|
||||
* `blocksuite/affine/blocks/surface/src/extensions/dom-element-renderer.ts`).
|
||||
* Import your renderer function (from step 1).
|
||||
* Use the factory to create your extension:
|
||||
* `export const MyCustomElementDomRendererExtension = DomElementRendererExtension('my-custom-element', myCustomElementRendererFn);`
|
||||
* Example: `ShapeDomRendererExtension` in `blocksuite/affine/gfx/shape/src/element-renderer/shape-dom.ts`.
|
||||
*
|
||||
* 3. **Register the Extension**:
|
||||
* In your application setup where BlockSuite services and view extensions are registered (e.g., a `ViewExtensionProvider`
|
||||
* or a central DI configuration place), import your new extension (from step 2) and register it with the
|
||||
* dependency injection container.
|
||||
* Example: `context.register(MyCustomElementDomRendererExtension);`
|
||||
* As seen with `ShapeDomRendererExtension` being registered in `blocksuite/affine/gfx/shape/src/view.ts`.
|
||||
*
|
||||
* 4. **Core Infrastructure (Provided by DomRenderer System)**:
|
||||
* - `DomElementRenderer` (type): The function signature for renderers, defined in
|
||||
* `blocksuite/affine/blocks/surface/src/renderer/dom-elements/index.ts`.
|
||||
* - `DomElementRendererIdentifier` (function): Creates unique service identifiers for DI,
|
||||
* used by `DomRenderer` to look up specific renderers. Defined in the same file.
|
||||
* - `DomElementRendererExtension` (factory): A helper to create extension objects for easy registration.
|
||||
* (e.g., from `@blocksuite/affine-block-surface` or its source).
|
||||
* - `DomRenderer._renderElement()`: This method automatically looks up the registered renderer using
|
||||
* `DomElementRendererIdentifier(elementType)` and calls it if found.
|
||||
*
|
||||
* 5. **Ensure Exports**:
|
||||
* - The `DomRenderer` class itself should be accessible (e.g., exported from `@blocksuite/affine/blocks/surface`).
|
||||
* - The `DomElementRendererExtension` factory should be accessible.
|
||||
*
|
||||
* By following these steps, `DomRenderer` will automatically pick up and use your custom rendering logic
|
||||
* when it encounters elements of 'my-custom-element' type.
|
||||
*/
|
||||
export class DomRenderer {
|
||||
private _container!: HTMLElement;
|
||||
|
||||
private readonly _disposables = new DisposableGroup();
|
||||
|
||||
private readonly _turboEnabled: () => boolean;
|
||||
|
||||
private readonly _overlays = new Set<Overlay>();
|
||||
|
||||
private _refreshRafId: number | null = null;
|
||||
|
||||
private _sizeUpdatedRafId: number | null = null;
|
||||
|
||||
private readonly _updateState: IncrementalUpdateState = {
|
||||
dirtyElementIds: new Set(),
|
||||
viewportDirty: false,
|
||||
sizeDirty: false,
|
||||
usePlaceholderDirty: false,
|
||||
pendingUpdates: new Map(),
|
||||
};
|
||||
|
||||
private readonly _pendingElements = new Map<string, SurfaceElementModel>();
|
||||
|
||||
private _lastViewportBounds: Bound | null = null;
|
||||
private _lastZoom: number | null = null;
|
||||
private _lastUsePlaceholder: boolean = false;
|
||||
|
||||
rootElement: HTMLElement;
|
||||
|
||||
private readonly _elementsMap = new Map<string, HTMLElement>();
|
||||
|
||||
std: BlockStdScope;
|
||||
|
||||
grid: GridManager;
|
||||
|
||||
layerManager: LayerManager;
|
||||
|
||||
provider: Partial<EnvProvider>;
|
||||
|
||||
private readonly _surfaceModel: SurfaceBlockModel;
|
||||
|
||||
usePlaceholder = false;
|
||||
|
||||
viewport: Viewport;
|
||||
|
||||
elementsUpdated = new Subject<{
|
||||
elements: HTMLElement[];
|
||||
added: HTMLElement[];
|
||||
removed: HTMLElement[];
|
||||
}>();
|
||||
|
||||
constructor(options: RendererOptions) {
|
||||
this.rootElement = document.createElement('div');
|
||||
this.rootElement.classList.add('dom-renderer-root');
|
||||
this.rootElement.style.pointerEvents = 'none';
|
||||
|
||||
this.std = options.std;
|
||||
this.viewport = options.viewport;
|
||||
this.layerManager = options.layerManager;
|
||||
this.grid = options.gridManager;
|
||||
this.provider = options.provider ?? {};
|
||||
this._surfaceModel = options.surfaceModel;
|
||||
|
||||
this._turboEnabled = () => {
|
||||
const featureFlagService = options.std.get(FeatureFlagService);
|
||||
return featureFlagService.getFlag('enable_turbo_renderer');
|
||||
};
|
||||
|
||||
this._initViewport();
|
||||
this._watchSurface(options.surfaceModel);
|
||||
}
|
||||
|
||||
private _initViewport() {
|
||||
this._disposables.add(
|
||||
this.viewport.viewportUpdated.subscribe(() => {
|
||||
if (
|
||||
this.viewport.SKIP_REFRESH_DURING_GESTURE &&
|
||||
(this.viewport.panning$.value || this.viewport.zooming$.value)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._markViewportDirty();
|
||||
this.refresh();
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.sizeUpdated.subscribe(() => {
|
||||
if (this._sizeUpdatedRafId) return;
|
||||
this._sizeUpdatedRafId = requestConnectedFrame(() => {
|
||||
this._sizeUpdatedRafId = null;
|
||||
this._markSizeDirty();
|
||||
this._resetSize();
|
||||
this._render();
|
||||
this.refresh();
|
||||
}, this._container);
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.zooming$.subscribe(isZooming => {
|
||||
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||
return;
|
||||
}
|
||||
const shouldRenderPlaceholders = this._turboEnabled() && isZooming;
|
||||
|
||||
if (this.usePlaceholder !== shouldRenderPlaceholders) {
|
||||
this.usePlaceholder = shouldRenderPlaceholders;
|
||||
this._markUsePlaceholderDirty();
|
||||
this.refresh();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Post-gesture refresh for SKIP mode
|
||||
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||
let pendingTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cancelRefresh = () => {
|
||||
if (pendingTimerId !== null) {
|
||||
clearTimeout(pendingTimerId);
|
||||
pendingTimerId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRefresh = () => {
|
||||
cancelRefresh();
|
||||
pendingTimerId = setTimeout(() => {
|
||||
pendingTimerId = null;
|
||||
if (!this.viewport.panning$.value && !this.viewport.zooming$.value) {
|
||||
this._markViewportDirty();
|
||||
this.refresh();
|
||||
}
|
||||
}, viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY);
|
||||
};
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.panning$.subscribe(panning => {
|
||||
if (panning) cancelRefresh();
|
||||
else if (!this.viewport.zooming$.value) scheduleRefresh();
|
||||
})
|
||||
);
|
||||
this._disposables.add(
|
||||
this.viewport.zooming$.subscribe(zooming => {
|
||||
if (zooming) cancelRefresh();
|
||||
else if (!this.viewport.panning$.value) scheduleRefresh();
|
||||
})
|
||||
);
|
||||
this._disposables.add({ dispose: cancelRefresh });
|
||||
}
|
||||
|
||||
this.usePlaceholder = false;
|
||||
}
|
||||
|
||||
private _resetSize() {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private _renderElement(
|
||||
elementModel: SurfaceElementModel,
|
||||
domElement: HTMLElement
|
||||
) {
|
||||
const renderFn = this.std.getOptional<DomElementRenderer>(
|
||||
DomElementRendererIdentifier(elementModel.type)
|
||||
);
|
||||
|
||||
if (renderFn) {
|
||||
renderFn(elementModel, domElement, this);
|
||||
} else {
|
||||
// If no specific renderer is found (e.g., for 'shape' if the extension isn't registered,
|
||||
// or for other element types without a dedicated DOM renderer),
|
||||
// no specific DOM styling will be applied here by _renderElement.
|
||||
// Basic properties like position/size are handled in the _render loop if usePlaceholder is false.
|
||||
console.warn(
|
||||
`No DOM renderer found for element type: ${elementModel.type}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _renderOrUpdatePlaceholder(
|
||||
elementModel: SurfaceElementModel,
|
||||
viewportBounds: Bound,
|
||||
zoom: number,
|
||||
addedElements: HTMLElement[]
|
||||
) {
|
||||
let domElement = this._elementsMap.get(elementModel.id);
|
||||
|
||||
if (!domElement) {
|
||||
domElement = document.createElement('div');
|
||||
domElement.dataset.elementId = elementModel.id;
|
||||
domElement.style.position = 'absolute';
|
||||
this._elementsMap.set(elementModel.id, domElement);
|
||||
this.rootElement.append(domElement);
|
||||
addedElements.push(domElement);
|
||||
}
|
||||
|
||||
domElement.style.backgroundColor = resolveSurfacePlaceholderColor(
|
||||
this.getColorScheme()
|
||||
);
|
||||
|
||||
const geometricStyles = calculatePlaceholderRect(
|
||||
elementModel,
|
||||
viewportBounds,
|
||||
zoom
|
||||
);
|
||||
const zIndexStyle = {
|
||||
'z-index': this.layerManager.getZIndex(elementModel),
|
||||
};
|
||||
Object.assign(domElement.style, geometricStyles, zIndexStyle);
|
||||
Object.assign(domElement.style, PLACEHOLDER_RESET_STYLES);
|
||||
|
||||
// Clear classes specific to shapes, if applicable
|
||||
if (elementModel.type === 'shape') {
|
||||
const shapeModel = elementModel as ShapeElementModel;
|
||||
domElement.classList.remove(`shape-${shapeModel.shapeType}`);
|
||||
domElement.classList.remove(
|
||||
`shape-style-${shapeModel.shapeStyle.toLowerCase()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _renderOrUpdateFullElement(
|
||||
elementModel: SurfaceElementModel,
|
||||
viewportBounds: Bound,
|
||||
zoom: number,
|
||||
addedElements: HTMLElement[]
|
||||
) {
|
||||
let domElement = this._elementsMap.get(elementModel.id);
|
||||
|
||||
if (!domElement) {
|
||||
domElement = document.createElement('div');
|
||||
domElement.dataset.elementId = elementModel.id;
|
||||
domElement.style.position = 'absolute';
|
||||
domElement.style.transformOrigin = 'top left';
|
||||
this._elementsMap.set(elementModel.id, domElement);
|
||||
this.rootElement.append(domElement);
|
||||
addedElements.push(domElement);
|
||||
}
|
||||
|
||||
const geometricStyles = calculateFullElementRect(
|
||||
elementModel,
|
||||
viewportBounds,
|
||||
zoom
|
||||
);
|
||||
const opacityStyle = getOpacity(elementModel);
|
||||
const zIndexStyle = {
|
||||
'z-index': this.layerManager.getZIndex(elementModel),
|
||||
};
|
||||
Object.assign(domElement.style, geometricStyles, opacityStyle, zIndexStyle);
|
||||
|
||||
this._renderElement(elementModel, domElement);
|
||||
}
|
||||
|
||||
private _render() {
|
||||
this._renderIncremental();
|
||||
}
|
||||
|
||||
private _watchSurface(surfaceModel: SurfaceBlockModel) {
|
||||
this._disposables.add(
|
||||
surfaceModel.elementAdded.subscribe(payload => {
|
||||
this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED);
|
||||
this._markViewportDirty();
|
||||
this.refresh();
|
||||
})
|
||||
);
|
||||
this._disposables.add(
|
||||
surfaceModel.elementRemoved.subscribe(payload => {
|
||||
this._markElementDirty(payload.id, UpdateType.ELEMENT_REMOVED);
|
||||
this._markViewportDirty();
|
||||
this.refresh();
|
||||
})
|
||||
);
|
||||
this._disposables.add(
|
||||
surfaceModel.localElementAdded.subscribe(payload => {
|
||||
this._markElementDirty(
|
||||
payload.id,
|
||||
UpdateType.ELEMENT_ADDED,
|
||||
payload as unknown as SurfaceElementModel
|
||||
);
|
||||
this._markViewportDirty();
|
||||
this.refresh();
|
||||
})
|
||||
);
|
||||
this._disposables.add(
|
||||
surfaceModel.localElementDeleted.subscribe(payload => {
|
||||
this._markElementDirty(payload.id, UpdateType.ELEMENT_REMOVED);
|
||||
this._markViewportDirty();
|
||||
this.refresh();
|
||||
})
|
||||
);
|
||||
this._disposables.add(
|
||||
surfaceModel.localElementUpdated.subscribe(payload => {
|
||||
this._markElementDirty(
|
||||
payload.model.id,
|
||||
UpdateType.ELEMENT_UPDATED,
|
||||
payload.model as unknown as SurfaceElementModel
|
||||
);
|
||||
if (payload.props['index'] || payload.props['groupId']) {
|
||||
this._markViewportDirty();
|
||||
}
|
||||
this.refresh();
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.add(
|
||||
surfaceModel.elementUpdated.subscribe(payload => {
|
||||
// ignore externalXYWH update cause it's updated by the renderer
|
||||
if (payload.props['externalXYWH']) return;
|
||||
this._markElementDirty(payload.id, UpdateType.ELEMENT_UPDATED);
|
||||
if (payload.props['index'] || payload.props['childIds']) {
|
||||
this._markViewportDirty();
|
||||
}
|
||||
this.refresh();
|
||||
})
|
||||
);
|
||||
|
||||
// Workaround for the group rendering reactive update when selection changed
|
||||
let lastSet = new Set<string>();
|
||||
this._disposables.add(
|
||||
this.std.selection.filter$(SurfaceSelection).subscribe(selections => {
|
||||
const groupRelatedSelection = new Set(
|
||||
selections.flatMap(s =>
|
||||
s.elements.flatMap(e => {
|
||||
const element = surfaceModel.getElementById(e);
|
||||
if (
|
||||
element &&
|
||||
(element.type === 'group' || element.groups.length !== 0)
|
||||
) {
|
||||
return [element.id, ...element.groups.map(g => g.id)];
|
||||
}
|
||||
return [];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (lastSet.symmetricDifference(groupRelatedSelection).size !== 0) {
|
||||
lastSet.union(groupRelatedSelection).forEach(g => {
|
||||
this._markElementDirty(g, UpdateType.ELEMENT_UPDATED);
|
||||
});
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
lastSet = groupRelatedSelection;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
addOverlay = (overlay: Overlay) => {
|
||||
overlay.setRenderer(null);
|
||||
this._overlays.add(overlay);
|
||||
this.refresh();
|
||||
};
|
||||
|
||||
attach = (container: HTMLElement) => {
|
||||
this._container = container;
|
||||
container.append(this.rootElement);
|
||||
|
||||
this._resetSize();
|
||||
this.refresh();
|
||||
};
|
||||
|
||||
dispose = () => {
|
||||
this._overlays.forEach(overlay => overlay.dispose());
|
||||
this._overlays.clear();
|
||||
this._disposables.dispose();
|
||||
|
||||
if (this._refreshRafId) {
|
||||
cancelAnimationFrame(this._refreshRafId);
|
||||
this._refreshRafId = null;
|
||||
}
|
||||
if (this._sizeUpdatedRafId) {
|
||||
cancelAnimationFrame(this._sizeUpdatedRafId);
|
||||
this._sizeUpdatedRafId = null;
|
||||
}
|
||||
|
||||
this.rootElement.remove();
|
||||
this._elementsMap.clear();
|
||||
};
|
||||
|
||||
generateColorProperty = (color: Color, fallback?: Color) => {
|
||||
return (
|
||||
this.provider.generateColorProperty?.(color, fallback) ?? 'transparent'
|
||||
);
|
||||
};
|
||||
|
||||
getColorScheme = () => {
|
||||
return this.provider.getColorScheme?.() ?? ColorScheme.Light;
|
||||
};
|
||||
|
||||
getColorValue = (color: Color, fallback?: Color, real?: boolean) => {
|
||||
return (
|
||||
this.provider.getColorValue?.(color, fallback, real) ?? 'transparent'
|
||||
);
|
||||
};
|
||||
|
||||
getPropertyValue = (property: string) => {
|
||||
return this.provider.getPropertyValue?.(property) ?? '';
|
||||
};
|
||||
|
||||
refresh = () => {
|
||||
if (this._refreshRafId !== null) return;
|
||||
|
||||
this._refreshRafId = requestConnectedFrame(() => {
|
||||
this._refreshRafId = null;
|
||||
this._render();
|
||||
}, this._container);
|
||||
};
|
||||
|
||||
removeOverlay = (overlay: Overlay) => {
|
||||
if (!this._overlays.has(overlay)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._overlays.delete(overlay);
|
||||
this.refresh();
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark a specific element as dirty for incremental updates
|
||||
* @param elementId - The ID of the element to mark as dirty
|
||||
* @param updateType - The type of update (optional, defaults to ELEMENT_UPDATED)
|
||||
*/
|
||||
markElementDirty = (
|
||||
elementId: string,
|
||||
updateType: UpdateType = UpdateType.ELEMENT_UPDATED
|
||||
) => {
|
||||
this._markElementDirty(elementId, updateType);
|
||||
};
|
||||
|
||||
/**
|
||||
* Force a full re-render of all elements
|
||||
*/
|
||||
forceFullRender = () => {
|
||||
this._updateState.viewportDirty = true;
|
||||
this.refresh();
|
||||
};
|
||||
|
||||
private _markElementDirty(
|
||||
elementId: string,
|
||||
updateType: UpdateType,
|
||||
elementModel?: SurfaceElementModel
|
||||
) {
|
||||
this._updateState.dirtyElementIds.add(elementId);
|
||||
if (updateType === UpdateType.ELEMENT_REMOVED) {
|
||||
this._pendingElements.delete(elementId);
|
||||
} else {
|
||||
const model =
|
||||
elementModel ?? this._surfaceModel.getElementById(elementId);
|
||||
if (model) {
|
||||
this._pendingElements.set(elementId, model as SurfaceElementModel);
|
||||
}
|
||||
}
|
||||
|
||||
const currentUpdates =
|
||||
this._updateState.pendingUpdates.get(elementId) || [];
|
||||
if (!currentUpdates.includes(updateType)) {
|
||||
currentUpdates.push(updateType);
|
||||
this._updateState.pendingUpdates.set(elementId, currentUpdates);
|
||||
}
|
||||
}
|
||||
|
||||
private _markViewportDirty() {
|
||||
this._updateState.viewportDirty = true;
|
||||
}
|
||||
|
||||
private _markSizeDirty() {
|
||||
this._updateState.sizeDirty = true;
|
||||
}
|
||||
|
||||
private _markUsePlaceholderDirty() {
|
||||
this._updateState.usePlaceholderDirty = true;
|
||||
}
|
||||
|
||||
private _clearUpdateState() {
|
||||
this._updateState.dirtyElementIds.clear();
|
||||
this._updateState.viewportDirty = false;
|
||||
this._updateState.sizeDirty = false;
|
||||
this._updateState.usePlaceholderDirty = false;
|
||||
this._updateState.pendingUpdates.clear();
|
||||
}
|
||||
|
||||
private _isViewportChanged(): boolean {
|
||||
const { viewportBounds, zoom } = this.viewport;
|
||||
|
||||
if (!this._lastViewportBounds || !this._lastZoom) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
this._lastViewportBounds.x !== viewportBounds.x ||
|
||||
this._lastViewportBounds.y !== viewportBounds.y ||
|
||||
this._lastViewportBounds.w !== viewportBounds.w ||
|
||||
this._lastViewportBounds.h !== viewportBounds.h ||
|
||||
this._lastZoom !== zoom
|
||||
);
|
||||
}
|
||||
|
||||
private _isUsePlaceholderChanged(): boolean {
|
||||
return this._lastUsePlaceholder !== this.usePlaceholder;
|
||||
}
|
||||
|
||||
private _elementInViewport(
|
||||
elementModel: SurfaceElementModel,
|
||||
viewportBounds: Bound
|
||||
) {
|
||||
const display = (elementModel.display ?? true) && !elementModel.hidden;
|
||||
return (
|
||||
display && intersects(getBoundWithRotation(elementModel), viewportBounds)
|
||||
);
|
||||
}
|
||||
|
||||
private _getPendingElementsInViewport(viewportBounds: Bound) {
|
||||
const elements: SurfaceElementModel[] = [];
|
||||
|
||||
for (const [id, elementModel] of this._pendingElements) {
|
||||
this._pendingElements.delete(id);
|
||||
if (this._elementInViewport(elementModel, viewportBounds)) {
|
||||
elements.push(elementModel);
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
private _getElementsInViewport(viewportBounds: Bound) {
|
||||
const elements = this.grid.search(viewportBounds, {
|
||||
filter: ['canvas', 'local'],
|
||||
}) as SurfaceElementModel[];
|
||||
|
||||
const elementsById = new Map<string, SurfaceElementModel>();
|
||||
for (const elementModel of elements) {
|
||||
if (this._elementInViewport(elementModel, viewportBounds)) {
|
||||
elementsById.set(elementModel.id, elementModel);
|
||||
this._pendingElements.delete(elementModel.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const elementModel of this._getPendingElementsInViewport(
|
||||
viewportBounds
|
||||
)) {
|
||||
elementsById.set(elementModel.id, elementModel);
|
||||
}
|
||||
|
||||
return Array.from(elementsById.values());
|
||||
}
|
||||
|
||||
private _updateLastState() {
|
||||
const { viewportBounds, zoom } = this.viewport;
|
||||
this._lastViewportBounds = {
|
||||
x: viewportBounds.x,
|
||||
y: viewportBounds.y,
|
||||
w: viewportBounds.w,
|
||||
h: viewportBounds.h,
|
||||
} as Bound;
|
||||
this._lastZoom = zoom;
|
||||
this._lastUsePlaceholder = this.usePlaceholder;
|
||||
}
|
||||
|
||||
private _renderIncremental() {
|
||||
const { viewportBounds, zoom } = this.viewport;
|
||||
const addedElements: HTMLElement[] = [];
|
||||
const elementsToRemove: HTMLElement[] = [];
|
||||
|
||||
const needsFullRender =
|
||||
this._isViewportChanged() ||
|
||||
this._isUsePlaceholderChanged() ||
|
||||
this._updateState.sizeDirty ||
|
||||
this._updateState.viewportDirty ||
|
||||
this._updateState.usePlaceholderDirty;
|
||||
|
||||
if (needsFullRender) {
|
||||
this._renderFull();
|
||||
this._updateLastState();
|
||||
this._clearUpdateState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Only update dirty elements
|
||||
const elementsInViewport = this._getElementsInViewport(viewportBounds);
|
||||
|
||||
const visibleElementIds = new Set<string>();
|
||||
|
||||
// 1. Update dirty elements
|
||||
for (const elementModel of elementsInViewport) {
|
||||
visibleElementIds.add(elementModel.id);
|
||||
|
||||
// Only update dirty elements
|
||||
if (this._updateState.dirtyElementIds.has(elementModel.id)) {
|
||||
if (
|
||||
this.usePlaceholder &&
|
||||
!(elementModel as GfxCompatibleInterface).forceFullRender
|
||||
) {
|
||||
this._renderOrUpdatePlaceholder(
|
||||
elementModel,
|
||||
viewportBounds,
|
||||
zoom,
|
||||
addedElements
|
||||
);
|
||||
} else {
|
||||
this._renderOrUpdateFullElement(
|
||||
elementModel,
|
||||
viewportBounds,
|
||||
zoom,
|
||||
addedElements
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove elements that are no longer in the grid
|
||||
for (const elementId of this._updateState.dirtyElementIds) {
|
||||
const updateTypes = this._updateState.pendingUpdates.get(elementId) || [];
|
||||
if (
|
||||
updateTypes.includes(UpdateType.ELEMENT_REMOVED) ||
|
||||
!visibleElementIds.has(elementId)
|
||||
) {
|
||||
const domElem = this._elementsMap.get(elementId);
|
||||
if (domElem) {
|
||||
domElem.remove();
|
||||
this._elementsMap.delete(elementId);
|
||||
elementsToRemove.push(domElem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Notify changes
|
||||
if (addedElements.length > 0 || elementsToRemove.length > 0) {
|
||||
this.elementsUpdated.next({
|
||||
elements: Array.from(this._elementsMap.values()),
|
||||
added: addedElements,
|
||||
removed: elementsToRemove,
|
||||
});
|
||||
}
|
||||
|
||||
this._updateLastState();
|
||||
this._clearUpdateState();
|
||||
}
|
||||
|
||||
private _renderFull() {
|
||||
const { viewportBounds, zoom } = this.viewport;
|
||||
const addedElements: HTMLElement[] = [];
|
||||
const elementsToRemove: HTMLElement[] = [];
|
||||
|
||||
const elementsInViewport = this._getElementsInViewport(viewportBounds);
|
||||
const visibleElementIds = new Set<string>();
|
||||
|
||||
for (const elementModel of elementsInViewport) {
|
||||
visibleElementIds.add(elementModel.id);
|
||||
|
||||
if (
|
||||
this.usePlaceholder &&
|
||||
!(elementModel as GfxCompatibleInterface).forceFullRender
|
||||
) {
|
||||
this._renderOrUpdatePlaceholder(
|
||||
elementModel,
|
||||
viewportBounds,
|
||||
zoom,
|
||||
addedElements
|
||||
);
|
||||
} else {
|
||||
this._renderOrUpdateFullElement(
|
||||
elementModel,
|
||||
viewportBounds,
|
||||
zoom,
|
||||
addedElements
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const currentRenderedElementIds = Array.from(this._elementsMap.keys());
|
||||
for (const id of currentRenderedElementIds) {
|
||||
if (!visibleElementIds.has(id)) {
|
||||
const domElem = this._elementsMap.get(id);
|
||||
if (domElem) {
|
||||
domElem.remove();
|
||||
this._elementsMap.delete(id);
|
||||
if (!elementsToRemove.includes(domElem)) {
|
||||
elementsToRemove.push(domElem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (addedElements.length > 0 || elementsToRemove.length > 0) {
|
||||
this.elementsUpdated.next({
|
||||
elements: Array.from(this._elementsMap.values()),
|
||||
added: addedElements,
|
||||
removed: elementsToRemove,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user