packages/ketcher-core/src/application/render/raphaelRender.ts (207 lines of code) (raw):
/****************************************************************************
* Copyright 2021 EPAM Systems
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
***************************************************************************/
import { Box2Abs, Struct, Vec2 } from 'domain/entities';
import { RaphaelPaper } from 'raphael';
import Raphael from './raphael-ext';
import { ReStruct } from './restruct';
import { Scale } from 'domain/helpers';
import defaultOptions from './options';
import draw from './draw';
import { RenderOptions, ViewBox } from './render.types';
import { KetcherLogger } from 'utilities';
import { CoordinateTransformation } from './coordinateTransformation';
import { ScrollbarContainer } from './scrollbar';
import { notifyRenderComplete } from './notifyRenderComplete';
export class Render {
public skipRaphaelInitialization = false;
public readonly clientArea: HTMLElement;
public readonly paper: RaphaelPaper;
// TODO https://github.com/epam/ketcher/issues/2631
public sz: Vec2;
// TODO https://github.com/epam/ketcher/issues/2630
public ctab: ReStruct;
public options: RenderOptions;
public viewBox!: ViewBox;
private readonly userOpts: RenderOptions;
private oldCb: Box2Abs | null = null;
private scrollbar: ScrollbarContainer;
private resizeObserver: ResizeObserver | null = null;
constructor(
clientArea: HTMLElement,
options: RenderOptions,
reuseRestructIfExist?: boolean,
) {
this.userOpts = options;
this.clientArea = clientArea;
this.paper = new Raphael(
clientArea,
options.width || '100%',
options.height || '100%',
);
this.sz = this.getCanvasSizeVector();
this.options = defaultOptions(this.userOpts);
if (reuseRestructIfExist && global.ketcher?.editor?.render?.ctab) {
this.ctab = global.ketcher?.editor?.render?.ctab;
this.ctab.render = this;
this.ctab.initLayers();
this.ctab.update(true);
} else {
this.ctab = new ReStruct(new Struct(), this);
}
this.scrollbar = new ScrollbarContainer(this);
this.setViewBox({
minX: 0,
minY: 0,
width: this.sz.x,
height: this.sz.y,
});
}
observeCanvasResize = () => {
this.resizeObserver = new ResizeObserver(() => {
this.sz = this.getCanvasSizeVector();
this.resizeViewBox();
});
this.resizeObserver.observe(this.paper.canvas);
};
unobserveCanvasResize = () => {
this.resizeObserver?.unobserve(this.paper.canvas);
};
updateOptions(opts: string) {
try {
const passedOptions = JSON.parse(opts);
if (passedOptions && typeof passedOptions === 'object') {
this.options = { ...this.options, ...passedOptions };
return this.options;
}
} catch (e) {
KetcherLogger.error('raphaelRenderer.ts::updateOptions', e);
console.log('Not a valid settings object');
}
return false;
}
selectionPolygon(polygon: Vec2[]) {
return draw.selectionPolygon(this.paper, polygon, this.options);
}
selectionLine(point0: Vec2, point1: Vec2) {
return draw.selectionLine(this.paper, point0, point1, this.options);
}
selectionRectangle(point0: Vec2, point1: Vec2) {
return draw.selectionRectangle(this.paper, point0, point1, this.options);
}
/** @deprecated recommend using `CoordinateTransformation.pageToModel` instead */
page2obj(event: MouseEvent | { clientX: number; clientY: number }) {
return CoordinateTransformation.pageToModel(event, this);
}
setZoom(zoom: number, event?: WheelEvent) {
const zoomedWidth = this.sz.x / zoom;
const zoomedHeight = this.sz.y / zoom;
const [viewBoxX, viewBoxY] = event
? this.zoomOnMouse(event, zoomedWidth, zoomedHeight)
: this.zoomOnCanvasCenter(zoomedWidth, zoomedHeight);
this.setViewBox({
minX: viewBoxX,
minY: viewBoxY,
width: zoomedWidth,
height: zoomedHeight,
});
this.options.zoom = zoom;
}
private getCanvasSizeVector() {
return this.userOpts.width
? new Vec2(this.userOpts.width, this.userOpts.height)
: new Vec2(this.clientArea.clientWidth, this.clientArea.clientHeight);
}
resizeViewBox() {
this.sz = this.getCanvasSizeVector();
const newWidth = this.sz.x / this.options.zoom;
const newHeight = this.sz.y / this.options.zoom;
this.setViewBox((prev) => ({
...prev,
width: newWidth,
height: newHeight,
}));
}
private zoomOnCanvasCenter(zoomedWidth: number, zoomedHeight: number) {
const fixedPoint = new Vec2(
this.viewBox.minX + this.viewBox.width / 2,
this.viewBox.minY + this.viewBox.height / 2,
);
const viewBoxX = fixedPoint.x - zoomedWidth / 2;
const viewBoxY = fixedPoint.y - zoomedHeight / 2;
return [viewBoxX, viewBoxY];
}
private zoomOnMouse(
event: WheelEvent,
zoomedWidth: number,
zoomedHeight: number,
) {
const fixedPoint = CoordinateTransformation.pageToCanvas(event, this);
const widthRatio = (fixedPoint.x - this.viewBox.minX) / this.viewBox.width;
const heightRatio =
(fixedPoint.y - this.viewBox.minY) / this.viewBox.height;
const viewBoxX = fixedPoint.x - zoomedWidth * widthRatio;
const viewBoxY = fixedPoint.y - zoomedHeight * heightRatio;
return [viewBoxX, viewBoxY];
}
/**
* @see https://developer.mozilla.org/zh-CN/docs/Web/SVG/Attribute/viewBox
*/
setViewBox(func: (viewBox: ViewBox) => ViewBox): void;
setViewBox(viewBox: ViewBox): void;
setViewBox(arg: ViewBox | ((viewBox: ViewBox) => ViewBox)): void {
const newViewBox = typeof arg === 'function' ? arg(this.viewBox) : arg;
this.viewBox = newViewBox;
this.paper.canvas.setAttribute(
'viewBox',
`${newViewBox.minX} ${newViewBox.minY} ${newViewBox.width} ${newViewBox.height}`,
);
this.scrollbar.update();
}
setMolecule(struct: Struct, forceUpdateWithTimeout = false) {
this.paper.clear(); // removes scrollbar rects also
this.ctab = new ReStruct(struct, this);
this.options.offset = new Vec2();
this.scrollbar.destroy();
this.scrollbar = new ScrollbarContainer(this);
// need to use force update with timeout to have ability select bonds in case of usage:
// addFragment, setMolecule or "Paste from clipboard" with "Open as New Project" button
if (forceUpdateWithTimeout) {
setTimeout(() => {
this.update(true);
}, 0);
} else {
this.update(false);
}
}
update(force = false, viewSz: Vec2 | null = null) {
// eslint-disable-line max-statements
viewSz =
viewSz ||
new Vec2(
this.userOpts.width || this.clientArea.clientWidth || 100,
this.userOpts.height || this.clientArea.clientHeight || 100,
);
const changes = this.ctab.update(force);
this.ctab.setSelection(); // [MK] redraw the selection bits where necessary
if (changes) {
const bb = this.ctab
.getVBoxObj()
.transform(Scale.modelToCanvas, this.options)
.translate(this.options.offset || new Vec2());
if (this.options.downScale) {
this.ctab.molecule.rescale();
}
const isAutoScale = this.options.autoScale || this.options.downScale;
if (!isAutoScale) {
if (!this.oldCb) this.oldCb = new Box2Abs();
this.scrollbar.update();
this.options.offset = this.options.offset || new Vec2();
} else {
const sz1 = bb.sz();
const marg = this.options.autoScaleMargin;
const mv = new Vec2(marg, marg);
const csz = viewSz;
if (marg && (csz.x < 2 * marg + 1 || csz.y < 2 * marg + 1)) {
throw new Error('View box too small for the given margin');
}
let rescale =
this.options.rescaleAmount ||
Math.max(sz1.x / (csz.x - 2 * marg), sz1.y / (csz.y - 2 * marg));
const isForceDownscale = this.options.downScale && rescale < 1;
const isBondsLengthFit = this.options.maxBondLength / rescale > 1;
if (isBondsLengthFit || isForceDownscale) {
rescale = 1;
}
const sz2 = sz1.add(mv.scaled(2 * rescale));
this.paper.setViewBox(
bb.pos().x - marg * rescale - (csz.x * rescale - sz2.x) / 2,
bb.pos().y - marg * rescale - (csz.y * rescale - sz2.y) / 2,
csz.x * rescale,
csz.y * rescale,
);
}
notifyRenderComplete();
}
}
}