208 lines
5.7 KiB
TypeScript
208 lines
5.7 KiB
TypeScript
import type {useSvelteFlow} from '@xyflow/svelte';
|
|
import {componentName} from './consts';
|
|
import {store} from './store/stores.svelte';
|
|
import type {TinyflowData, TinyflowOptions, TinyflowTheme} from './types';
|
|
|
|
type FlowInstance = ReturnType<typeof useSvelteFlow>;
|
|
|
|
export class Tinyflow {
|
|
private options!: TinyflowOptions;
|
|
private rootEl!: Element;
|
|
private svelteFlowInstance!: FlowInstance;
|
|
private tinyflowEl!: HTMLElement & {
|
|
options: TinyflowOptions;
|
|
onInit: (svelteFlowInstance: FlowInstance) => void;
|
|
};
|
|
|
|
constructor(options: TinyflowOptions) {
|
|
if (
|
|
typeof options.element !== 'string' &&
|
|
!(options.element instanceof Element)
|
|
) {
|
|
throw new Error('element must be a string or Element');
|
|
}
|
|
this._setOptions(options);
|
|
this._init();
|
|
}
|
|
|
|
private _init() {
|
|
if (typeof this.options.element === 'string') {
|
|
this.rootEl = document.querySelector(this.options.element)!;
|
|
if (!this.rootEl) {
|
|
throw new Error(
|
|
`element not found by document.querySelector('${this.options.element}')`,
|
|
);
|
|
}
|
|
} else if (this.options.element instanceof Element) {
|
|
this.rootEl = this.options.element;
|
|
} else {
|
|
throw new Error('element must be a string or Element');
|
|
}
|
|
|
|
this.tinyflowEl = this._createTinyflowElement();
|
|
this.rootEl.appendChild(this.tinyflowEl);
|
|
}
|
|
|
|
private _setOptions(options: TinyflowOptions) {
|
|
this.options = {
|
|
theme: options.theme || 'light',
|
|
...options,
|
|
};
|
|
}
|
|
|
|
private _getFlowInstance() {
|
|
if (!this.svelteFlowInstance) {
|
|
console.warn('Tinyflow instance is not initialized');
|
|
return null;
|
|
}
|
|
return this.svelteFlowInstance;
|
|
}
|
|
|
|
private _applyThemeClass(targetEl: Element, theme?: TinyflowTheme) {
|
|
targetEl.classList.remove('tf-theme-light', 'tf-theme-dark');
|
|
targetEl.classList.add(
|
|
theme === 'dark' ? 'tf-theme-dark' : 'tf-theme-light',
|
|
);
|
|
}
|
|
|
|
private _createTinyflowElement() {
|
|
const tinyflowEl = document.createElement(componentName) as HTMLElement & {
|
|
options: TinyflowOptions;
|
|
onInit: (svelteFlowInstance: FlowInstance) => void;
|
|
};
|
|
tinyflowEl.style.display = 'block';
|
|
tinyflowEl.style.width = '100%';
|
|
tinyflowEl.style.height = '100%';
|
|
this._applyThemeClass(tinyflowEl, this.options.theme);
|
|
|
|
tinyflowEl.options = this.options;
|
|
tinyflowEl.onInit = (svelteFlowInstance: FlowInstance) => {
|
|
this.svelteFlowInstance = svelteFlowInstance;
|
|
};
|
|
return tinyflowEl;
|
|
}
|
|
|
|
getOptions() {
|
|
return this.options;
|
|
}
|
|
|
|
getData() {
|
|
const flow = this._getFlowInstance();
|
|
if (!flow) {
|
|
return null;
|
|
}
|
|
return flow.toObject();
|
|
}
|
|
|
|
updateData(data: TinyflowData, options?: { preserveViewport?: boolean }) {
|
|
const flow = this._getFlowInstance();
|
|
if (!flow) {
|
|
return false;
|
|
}
|
|
|
|
const currentViewport = flow.getViewport();
|
|
const currentNodes = flow.getNodes();
|
|
const currentNodePositions = new Map(
|
|
currentNodes.map((node) => [node.id, node.position]),
|
|
);
|
|
const nextNodes =
|
|
options?.preserveViewport === true
|
|
? (data.nodes || currentNodes).map((node) => {
|
|
const currentPosition = currentNodePositions.get(node.id);
|
|
return currentPosition
|
|
? { ...node, position: { ...currentPosition } }
|
|
: node;
|
|
})
|
|
: data.nodes || currentNodes;
|
|
store.setNodes(nextNodes);
|
|
store.setEdges(data.edges || flow.getEdges());
|
|
|
|
if (data.viewport && options?.preserveViewport !== true) {
|
|
flow.setViewport(data.viewport, { duration: 0 });
|
|
} else {
|
|
flow.setViewport(currentViewport, { duration: 0 });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async focusNode(
|
|
nodeId: string,
|
|
options?: { duration?: number; zoom?: number },
|
|
) {
|
|
const flow = this._getFlowInstance();
|
|
if (!flow) {
|
|
return false;
|
|
}
|
|
|
|
const targetNode = flow.getNode(nodeId);
|
|
if (!targetNode) {
|
|
return false;
|
|
}
|
|
|
|
// Keep only the target node selected so the canvas has a clear visual focus.
|
|
flow.getNodes().forEach((node) => {
|
|
flow.updateNode(node.id, { selected: node.id === nodeId });
|
|
});
|
|
|
|
const internalNode = flow.getInternalNode(nodeId) as any;
|
|
const absolutePosition = internalNode?.internals?.positionAbsolute ||
|
|
(targetNode as any)?.positionAbsolute ||
|
|
targetNode.position || { x: 0, y: 0 };
|
|
const width =
|
|
internalNode?.measured?.width ||
|
|
(targetNode as any)?.measured?.width ||
|
|
(targetNode as any)?.width ||
|
|
260;
|
|
const height =
|
|
internalNode?.measured?.height ||
|
|
(targetNode as any)?.measured?.height ||
|
|
(targetNode as any)?.height ||
|
|
120;
|
|
|
|
const centerX = absolutePosition.x + width / 2;
|
|
const centerY = absolutePosition.y + height / 2;
|
|
const nextZoom = options?.zoom ?? Math.max(flow.getZoom(), 1);
|
|
|
|
await flow.setCenter(centerX, centerY, {
|
|
zoom: nextZoom,
|
|
duration: options?.duration ?? 280,
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
async fitView(options?: { duration?: number; padding?: number }) {
|
|
const flow = this._getFlowInstance();
|
|
if (!flow) {
|
|
return false;
|
|
}
|
|
await flow.fitView({
|
|
duration: options?.duration ?? 220,
|
|
padding: options?.padding ?? 0.2,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
setTheme(theme: TinyflowTheme) {
|
|
this.options.theme = theme;
|
|
if (this.tinyflowEl) {
|
|
this._applyThemeClass(this.tinyflowEl, theme);
|
|
}
|
|
}
|
|
|
|
setData(data: TinyflowData) {
|
|
this.options.data = data;
|
|
|
|
this.tinyflowEl = this._createTinyflowElement();
|
|
|
|
this.destroy();
|
|
this.rootEl.appendChild(this.tinyflowEl);
|
|
}
|
|
|
|
destroy() {
|
|
while (this.rootEl.firstChild) {
|
|
this.rootEl.removeChild(this.rootEl.firstChild);
|
|
}
|
|
}
|
|
}
|