Skip to content

Commit

Permalink
#4539 - Export macromolecules canvas as svg image
Browse files Browse the repository at this point in the history
  • Loading branch information
ilya-asiyuk-epam committed May 13, 2024
1 parent 560f410 commit 242c998
Show file tree
Hide file tree
Showing 14 changed files with 177 additions and 20 deletions.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/ketcher-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/ketcher-core/src/application/editor/tools/Zoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
42 changes: 42 additions & 0 deletions packages/ketcher-core/src/application/ketcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* limitations under the License.
***************************************************************************/

import { saveAs } from 'file-saver';
import { FormatterFactory, SupportedFormat } from './formatters';
import { GenerateImageOptions, StructService } from 'domain/services';

Expand All @@ -28,6 +29,7 @@ import {
LogLevel,
runAsyncAction,
SettingsManager,
getSvgFromDrawnStructures,
} from 'utilities';
import {
deleteAllEntitiesOnCanvas,
Expand All @@ -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',
Expand Down Expand Up @@ -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<Struct> {
if (window.isPolymerEditorTurnedOn) {
throw new Error('Recognize is not available in macro mode');
Expand Down
16 changes: 16 additions & 0 deletions packages/ketcher-core/src/application/ketcher.types.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -360,15 +361,17 @@ 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')
.attr('r', '21px')
.attr('opacity', '0.7')
.attr('cx', this.center.x)
.attr('cy', this.center.y)
.attr('fill', '#57FF8F');
.attr('fill', '#57FF8F')
.attr('class', 'dynamic-element');
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion packages/ketcher-core/src/domain/AttachmentPoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
46 changes: 46 additions & 0 deletions packages/ketcher-core/src/utilities/getSvgFromDrawnStructures.tsx
Original file line number Diff line number Diff line change
@@ -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 `<svg width='100%' height='100%' style='position: absolute' viewBox='${viewBox}'>${svgInnerHTML}</svg>`;
else if (type === 'file')
return `<svg width='${viewBoxWidth}' height='${viewBoxHeight}' viewBox='${viewBox}' xmlns='${SVG_NAMESPACE_URI}'>${svgInnerHTML}</svg>`;
else return `<svg xmlns='${SVG_NAMESPACE_URI}' />`;
};
1 change: 1 addition & 0 deletions packages/ketcher-core/src/utilities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ export * from './SettingsManager';
export * from './keynorm';
export * from './shortcutsUtil';
export * from './clipboardUtils';
export * from './getSvgFromDrawnStructures';
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
}));
53 changes: 39 additions & 14 deletions packages/ketcher-macromolecules/src/components/modal/save/Save.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
StructService,
CoreEditor,
KetcherLogger,
getSvgFromDrawnStructures,
} from 'ketcher-core';
import { saveAs } from 'file-saver';
import { RequiredModalProps } from '../modalContainer';
Expand All @@ -39,6 +40,7 @@ import {
Row,
StyledDropdown,
stylesForExpanded,
SvgPreview,
} from './Save.styles';
import styled from '@emotion/styled';
import { useAppDispatch } from 'hooks';
Expand All @@ -50,6 +52,7 @@ const options: Array<Option> = [
{ id: 'sequence', label: 'Sequence' },
{ id: 'fasta', label: 'FASTA' },
{ id: 'idt', label: 'IDT' },
{ id: 'svg', label: 'SVG Document' },
];

const formatDetector = {
Expand Down Expand Up @@ -82,6 +85,7 @@ export const Save = ({
const [currentFileName, setCurrentFileName] = useState('ketcher');
const [struct, setStruct] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [svgData, setSvgData] = useState<string | undefined>();
const indigo = IndigoProvider.getIndigo() as StructService;
const editor = CoreEditor.provideEditorInstance();

Expand All @@ -92,10 +96,16 @@ export const Save = ({
editor.drawingEntitiesManager.micromoleculesHiddenEntities.clone(),
editor.drawingEntitiesManager,
);
setSvgData(undefined);
if (fileFormat === 'ket') {
setStruct(serializedKet);
return;
}
if (fileFormat === 'svg') {
const svgData = getSvgFromDrawnStructures(editor.canvas, 'preview');
setSvgData(svgData);
return;
}

try {
setIsLoading(true);
Expand Down Expand Up @@ -134,7 +144,18 @@ export const Save = ({
};

const handleSave = () => {
const blob = new Blob([struct], {
let blobPart;
if (currentFileFormat === 'svg') {
const svgData = getSvgFromDrawnStructures(editor.canvas, 'file');
if (!svgData) {
onClose();
return;
}
blobPart = svgData;
} else {
blobPart = struct;
}
const blob = new Blob([blobPart], {
type: getPropertiesByFormat(currentFileFormat).mime,
});
const formatProperties = getPropertiesByFormat(currentFileFormat);
Expand Down Expand Up @@ -174,19 +195,23 @@ export const Save = ({
customStylesForExpanded={stylesForExpanded}
/>
</Row>
<div style={{ display: 'flex', flexGrow: 1, position: 'relative' }}>
<TextArea
testId="preview-area-text"
value={struct}
readonly
selectOnInit
/>
{isLoading && (
<Loader>
<LoadingCircles />
</Loader>
)}
</div>
{svgData ? (
<SvgPreview dangerouslySetInnerHTML={{ __html: svgData }} />
) : (
<div style={{ display: 'flex', flexGrow: 1, position: 'relative' }}>
<TextArea
testId="preview-area-text"
value={struct}
readonly
selectOnInit
/>
{isLoading && (
<Loader>
<LoadingCircles />
</Loader>
)}
</div>
)}
</Form>
</Modal.Content>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ import {
SupportedFormatProperties,
} from './supportedFormatProperties';

export type SupportedFormats = 'mol' | 'ket' | 'fasta' | 'sequence' | 'idt';
export type SupportedFormats =
| 'mol'
| 'ket'
| 'fasta'
| 'sequence'
| 'idt'
| 'svg';

type FormatProperties = {
[key in SupportedFormats]: SupportedFormatProperties;
Expand Down Expand Up @@ -61,6 +67,9 @@ const formatProperties: FormatProperties = {
false,
{},
),
svg: new SupportedFormatProperties('SVG Document', ChemicalMimeType.Svg, [
'.svg',
]),
};

export const getPropertiesByFormat = (format: SupportedFormats) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export enum ChemicalMimeType {
Fasta = 'chemical/x-fasta',
Sequence = 'chemical/x-sequence',
Idt = 'chemical/x-idt',
Svg = 'image/svg+xml',
}

interface SupportedFormatPropertiesOptions {
Expand Down

0 comments on commit 242c998

Please sign in to comment.