diff --git a/package-lock.json b/package-lock.json index 6ada58d758..b14ae2d72d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31468,6 +31468,7 @@ "ajv": "^8.10.0", "assert": "^2.0.0", "d3": "^7.8.5", + "file-saver": "^2.0.5", "lodash": "^4.17.21", "raphael": "^2.3.0", "react-device-detect": "^2.2.2", diff --git a/packages/ketcher-core/package.json b/packages/ketcher-core/package.json index 45d75e9eeb..ba556508e5 100644 --- a/packages/ketcher-core/package.json +++ b/packages/ketcher-core/package.json @@ -47,6 +47,7 @@ "ajv": "^8.10.0", "assert": "^2.0.0", "d3": "^7.8.5", + "file-saver": "^2.0.5", "lodash": "^4.17.21", "raphael": "^2.3.0", "react-device-detect": "^2.2.2", diff --git a/packages/ketcher-core/src/application/editor/tools/Zoom.ts b/packages/ketcher-core/src/application/editor/tools/Zoom.ts index a82ced3840..e74bedd843 100644 --- a/packages/ketcher-core/src/application/editor/tools/Zoom.ts +++ b/packages/ketcher-core/src/application/editor/tools/Zoom.ts @@ -322,6 +322,10 @@ class ZoomTool implements BaseTool { this.zoom?.scaleTo(this.canvasWrapper, this.zoomLevel - zoomStep); } + public zoomTo(zoomLevel: number) { + this.zoom?.scaleTo(this.canvasWrapper, zoomLevel); + } + public resetZoom() { this.zoom?.transform(this.canvasWrapper, new ZoomTransform(1, 0, 0)); } diff --git a/packages/ketcher-core/src/application/ketcher.ts b/packages/ketcher-core/src/application/ketcher.ts index 57dc6f1ff0..88e64e5b24 100644 --- a/packages/ketcher-core/src/application/ketcher.ts +++ b/packages/ketcher-core/src/application/ketcher.ts @@ -14,6 +14,7 @@ * limitations under the License. ***************************************************************************/ +import { saveAs } from 'file-saver'; import { FormatterFactory, SupportedFormat } from './formatters'; import { GenerateImageOptions, StructService } from 'domain/services'; @@ -28,6 +29,7 @@ import { LogLevel, runAsyncAction, SettingsManager, + getSvgFromDrawnStructures, } from 'utilities'; import { deleteAllEntitiesOnCanvas, @@ -36,6 +38,13 @@ import { prepareStructToRender, } from './utils'; import { EditorSelection } from './editor/editor.types'; +import { + BlobTypes, + ExportImageParams, + ModeTypes, + SupportedImageFormats, + SupportedModes, +} from 'application/ketcher.types'; const allowedApiSettings = { 'general.dearomatize-on-load': 'dearomatize-on-load', @@ -389,6 +398,39 @@ export class Ketcher { }, this.eventBus); } + /** + * @param {number} value - in a range [ZoomTool.instance.MINZOOMSCALE, ZoomTool.instance.MAXZOOMSCALE] + */ + setZoom(value: number) { + const editor = CoreEditor.provideEditorInstance(); + if (editor && value) editor.zoomTool.zoomTo(value); + } + + setMode(mode: SupportedModes) { + const editor = CoreEditor.provideEditorInstance(); + if (editor && mode) editor.events.selectMode.dispatch(ModeTypes[mode]); + } + + exportImage(format: SupportedImageFormats, params: ExportImageParams) { + const editor = CoreEditor.provideEditorInstance(); + const fileName = 'ketcher'; + let blobPart; + + if (format === 'svg') { + blobPart = getSvgFromDrawnStructures( + editor.canvas, + 'file', + params?.margin, + ); + } + if (!blobPart) return; + + const blob = new Blob([blobPart], { + type: BlobTypes[format], + }); + saveAs(blob, `${fileName}.${format}`); + } + recognize(image: Blob, version?: string): Promise { if (window.isPolymerEditorTurnedOn) { throw new Error('Recognize is not available in macro mode'); diff --git a/packages/ketcher-core/src/application/ketcher.types.ts b/packages/ketcher-core/src/application/ketcher.types.ts new file mode 100644 index 0000000000..54515ed7df --- /dev/null +++ b/packages/ketcher-core/src/application/ketcher.types.ts @@ -0,0 +1,16 @@ +// Types for 'setMode' +export enum ModeTypes { + flex = 'flex-layout-mode', + snake = 'snake-layout-mode', + sequence = 'sequence-layout-mode', +} +export type SupportedModes = keyof typeof ModeTypes; + +// Types for 'exportImage' +export enum BlobTypes { + svg = 'image/svg+xml', +} +export type SupportedImageFormats = keyof typeof BlobTypes; +export type ExportImageParams = { + margin?: number; +}; diff --git a/packages/ketcher-core/src/application/render/renderers/BaseMonomerRenderer.ts b/packages/ketcher-core/src/application/render/renderers/BaseMonomerRenderer.ts index 120a8f5111..5903f87fd8 100644 --- a/packages/ketcher-core/src/application/render/renderers/BaseMonomerRenderer.ts +++ b/packages/ketcher-core/src/application/render/renderers/BaseMonomerRenderer.ts @@ -322,7 +322,8 @@ export abstract class BaseMonomerRenderer extends BaseRenderer { return hoverAreaElement .append('use') .attr('href', this.monomerHoveredElementId) - .attr('pointer-events', 'none'); + .attr('pointer-events', 'none') + .attr('class', 'dynamic-element'); } public removeHover() { @@ -360,7 +361,8 @@ export abstract class BaseMonomerRenderer extends BaseRenderer { ?.append('use') .attr('href', this.monomerSelectedElementId) .attr('stroke', '#57FF8F') - .attr('pointer-events', 'none'); + .attr('pointer-events', 'none') + .attr('class', 'dynamic-element'); this.selectionCircle = this.canvas ?.insert('circle', ':first-child') @@ -368,7 +370,8 @@ export abstract class BaseMonomerRenderer extends BaseRenderer { .attr('opacity', '0.7') .attr('cx', this.center.x) .attr('cy', this.center.y) - .attr('fill', '#57FF8F'); + .attr('fill', '#57FF8F') + .attr('class', 'dynamic-element'); } } diff --git a/packages/ketcher-core/src/application/render/renderers/PolymerBondRenderer.ts b/packages/ketcher-core/src/application/render/renderers/PolymerBondRenderer.ts index 846618512e..c56b2ecbad 100644 --- a/packages/ketcher-core/src/application/render/renderers/PolymerBondRenderer.ts +++ b/packages/ketcher-core/src/application/render/renderers/PolymerBondRenderer.ts @@ -529,7 +529,8 @@ export class PolymerBondRenderer extends BaseRenderer { .attr('y1', this.scaledPosition.startPosition.y) .attr('x2', this.scaledPosition.endPosition.x) .attr('y2', this.scaledPosition.endPosition.y) - .attr('stroke-width', '5'); + .attr('stroke-width', '5') + .attr('class', 'dynamic-element'); } } else { this.selectionElement?.remove(); diff --git a/packages/ketcher-core/src/domain/AttachmentPoint.ts b/packages/ketcher-core/src/domain/AttachmentPoint.ts index 51063e8c45..1e5a816c05 100644 --- a/packages/ketcher-core/src/domain/AttachmentPoint.ts +++ b/packages/ketcher-core/src/domain/AttachmentPoint.ts @@ -109,7 +109,8 @@ export class AttachmentPoint { this.attachmentPoint = this.rootElement .insert('g', ':first-child') .data([this]) - .style('pointer-events', 'none'); + .style('pointer-events', 'none') + .attr('class', 'dynamic-element'); const attachmentPointElement = this.attachmentPoint.append('g'); diff --git a/packages/ketcher-core/src/utilities/getSvgFromDrawnStructures.tsx b/packages/ketcher-core/src/utilities/getSvgFromDrawnStructures.tsx new file mode 100644 index 0000000000..4dd8712750 --- /dev/null +++ b/packages/ketcher-core/src/utilities/getSvgFromDrawnStructures.tsx @@ -0,0 +1,46 @@ +import { KetcherLogger } from 'utilities'; + +const SVG_NAMESPACE_URI = 'http://www.w3.org/2000/svg'; +const ADDITIONAL_TOP_MARGIN = 54; +const ADDITIONAL_LEFT_MARGIN = 50; +const DEFAULT_MARGIN = 10; + +export const getSvgFromDrawnStructures = ( + canvas: SVGSVGElement, + type: 'preview' | 'file', + margin = DEFAULT_MARGIN, +) => { + // Copy and clean up svg structures before previewing or saving + let svgInnerHTML = canvas?.innerHTML || ''; + const wrapper = document.createElementNS(SVG_NAMESPACE_URI, 'svg'); + wrapper.innerHTML = svgInnerHTML; + // remove #rectangle-selection-area + wrapper.querySelector('#rectangle-selection-area')?.remove(); + // remove dynamic elements (highlighters, attachment points...) + wrapper.querySelectorAll('.dynamic-element')?.forEach((el) => el.remove()); + svgInnerHTML = wrapper.innerHTML; + // remove "cursor: pointer" style + svgInnerHTML = svgInnerHTML?.replaceAll('cursor: pointer;', ''); + + const drawStructureClientRect = canvas + ?.getElementsByClassName('drawn-structures')[0] + .getBoundingClientRect(); + + if (!drawStructureClientRect || !svgInnerHTML) { + const errorMessage = 'Cannot get drawn structures!'; + KetcherLogger.error(errorMessage); + return; + } + + const viewBoxX = drawStructureClientRect.x - ADDITIONAL_LEFT_MARGIN - margin; + const viewBoxY = drawStructureClientRect.y - ADDITIONAL_TOP_MARGIN - margin; + const viewBoxWidth = drawStructureClientRect.width + margin * 2; + const viewBoxHeight = drawStructureClientRect.height + margin * 2; + const viewBox = `${viewBoxX} ${viewBoxY} ${viewBoxWidth} ${viewBoxHeight}`; + + if (type === 'preview') + return `${svgInnerHTML}`; + else if (type === 'file') + return `${svgInnerHTML}`; + else return ``; +}; diff --git a/packages/ketcher-core/src/utilities/index.ts b/packages/ketcher-core/src/utilities/index.ts index 48d8de383d..ceac9bc739 100644 --- a/packages/ketcher-core/src/utilities/index.ts +++ b/packages/ketcher-core/src/utilities/index.ts @@ -24,3 +24,4 @@ export * from './SettingsManager'; export * from './keynorm'; export * from './shortcutsUtil'; export * from './clipboardUtils'; +export * from './getSvgFromDrawnStructures'; diff --git a/packages/ketcher-macromolecules/src/components/modal/save/Save.styles.ts b/packages/ketcher-macromolecules/src/components/modal/save/Save.styles.ts index 53ee818091..dcbb669bd3 100644 --- a/packages/ketcher-macromolecules/src/components/modal/save/Save.styles.ts +++ b/packages/ketcher-macromolecules/src/components/modal/save/Save.styles.ts @@ -44,3 +44,9 @@ export const Loader = styled.div` align-items: center; background: #fff; `; + +export const SvgPreview = styled('div')(({ theme }) => ({ + height: '100%', + position: 'relative', + border: `1px solid ${theme.ketcher.color.input.border.regular}`, +})); diff --git a/packages/ketcher-macromolecules/src/components/modal/save/Save.tsx b/packages/ketcher-macromolecules/src/components/modal/save/Save.tsx index a3ea104c3c..aa59e95d07 100644 --- a/packages/ketcher-macromolecules/src/components/modal/save/Save.tsx +++ b/packages/ketcher-macromolecules/src/components/modal/save/Save.tsx @@ -29,6 +29,7 @@ import { StructService, CoreEditor, KetcherLogger, + getSvgFromDrawnStructures, } from 'ketcher-core'; import { saveAs } from 'file-saver'; import { RequiredModalProps } from '../modalContainer'; @@ -39,6 +40,7 @@ import { Row, StyledDropdown, stylesForExpanded, + SvgPreview, } from './Save.styles'; import styled from '@emotion/styled'; import { useAppDispatch } from 'hooks'; @@ -50,6 +52,7 @@ const options: Array