Skip to content

Commit

Permalink
Merge pull request #896 from UC-Davis-molecular-computing/dev
Browse files Browse the repository at this point in the history
Dev
  • Loading branch information
dave-doty committed Aug 5, 2023
2 parents 21a5a6e + c9830cf commit 32fbac9
Show file tree
Hide file tree
Showing 12 changed files with 222 additions and 40 deletions.
19 changes: 18 additions & 1 deletion lib/src/actions/actions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -924,6 +924,23 @@ abstract class LoadingDialogHide
static Serializer<LoadingDialogHide> get serializer => _$loadingDialogHideSerializer;
}

/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Copy SVG to clipboard

abstract class CopySelectedStandsToClipboardImage
with BuiltJsonSerializable
implements Action, Built<CopySelectedStandsToClipboardImage, CopySelectedStandsToClipboardImageBuilder> {
/************************ begin BuiltValue boilerplate ************************/
factory CopySelectedStandsToClipboardImage(
[void Function(CopySelectedStandsToClipboardImageBuilder) updates]) =
_$CopySelectedStandsToClipboardImage;

CopySelectedStandsToClipboardImage._();

static Serializer<CopySelectedStandsToClipboardImage> get serializer =>
_$copySelectedStandsToClipboardImageSerializer;
}

/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Save/load files

Expand Down Expand Up @@ -2055,7 +2072,7 @@ abstract class ExportCanDoDNA
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Export SVG

enum ExportSvgType { main, side, both }
enum ExportSvgType { main, side, both, selected }

abstract class ExportSvg with BuiltJsonSerializable implements Action, Built<ExportSvg, ExportSvgBuilder> {
factory ExportSvg.from([void Function(ExportSvgBuilder) updates]) = _$ExportSvg;
Expand Down
12 changes: 12 additions & 0 deletions lib/src/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ class App {
initialize_state();
setup_undo_redo_keyboard_listeners();
setup_save_open_dna_file_keyboard_listeners();
copy_selected_strands_to_clipboard_image_keyboard_listeners();
// util.save_editor_content_to_js_context(state.editor_content);
restore_all_local_storage(app.store);
setup_warning_before_unload();
Expand Down Expand Up @@ -276,3 +277,14 @@ setup_save_open_dna_file_keyboard_listeners() {
}
});
}

copy_selected_strands_to_clipboard_image_keyboard_listeners() {
document.body.onKeyDown.listen((KeyboardEvent event) {
int key = event.which;
// Ctrl+I to copy image of selected strands to clipboard
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && key == KeyCode.I && !event.altKey) {
event.preventDefault();
app.dispatch(actions.CopySelectedStandsToClipboardImage());
}
});
}
4 changes: 2 additions & 2 deletions lib/src/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import 'state/grid.dart';

// WARNING: Do not modify line below, except for the version string
// (and also add new version string to scadnano_versions_to_link).
const String CURRENT_VERSION = "0.18.2";
const String CURRENT_VERSION = "0.18.3";
const String INITIAL_VERSION = "0.1.0";

// scadnano versions that we deploy so that older versions can be used.
final scadnano_older_versions_to_link = [
"0.18.1",
"0.18.2",
"0.17.14",
// "0.17.13",
// "0.17.12",
Expand Down
138 changes: 108 additions & 30 deletions lib/src/middleware/export_svg.dart
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import 'dart:html';
import 'dart:svg' as svg;
import 'dart:svg';

import 'package:redux/redux.dart';
import 'package:scadnano/src/middleware/system_clipboard.dart';

import '../app.dart';
import '../state/app_state.dart';
import '../actions/actions.dart' as actions;
import '../util.dart' as util;

export_svg_middleware(Store<AppState> store, dynamic action, NextDispatcher next) {
if (action is actions.ExportSvg) {
if (action is actions.ExportSvg || action is actions.CopySelectedStandsToClipboardImage) {
var ui_state = store.state.ui_state;
var dna_sequence_png_uri = ui_state.dna_sequence_png_uri;
var is_zoom_above_threshold = ui_state.is_zoom_above_threshold;
Expand All @@ -20,7 +22,9 @@ export_svg_middleware(Store<AppState> store, dynamic action, NextDispatcher next
export_svg_action_delayed_for_png_cache, disable_png_caching_dna_sequences);

// If main needs to be exported, then the png needs to be disabled if currently being used.
bool need_to_disable_png = (action.type == actions.ExportSvgType.main) && using_png_dna_sequence;
bool need_to_disable_png =
(action is actions.CopySelectedStandsToClipboardImage || action.type == actions.ExportSvgType.main) &&
using_png_dna_sequence;

if (need_to_disable_png) {
// Disables the png
Expand All @@ -29,49 +33,115 @@ export_svg_middleware(Store<AppState> store, dynamic action, NextDispatcher next
// Note that the ExportSvgType action cannot be dispatched in this if branch because we need to
// let this middleware resolve so that React can render the DNA sequences as an SVG.
} else {
// Exports the appropriate svgs.
if (action.type == actions.ExportSvgType.main || action.type == actions.ExportSvgType.both) {
var elt = document.getElementById("main-view-svg");
_export_from_element(elt, 'main');
}
if (action.type == actions.ExportSvgType.side || action.type == actions.ExportSvgType.both) {
var elt = document.getElementById("side-view-svg");
_export_from_element(elt, 'side');
if (action is actions.ExportSvg) {
// Exports the appropriate svgs.
if (action.type == actions.ExportSvgType.main ||
action.type == actions.ExportSvgType.both ||
action.type == actions.ExportSvgType.selected) {
var elt = document.getElementById("main-view-svg");
if (action.type == actions.ExportSvgType.selected) {
List<Element> selected_elts = get_selected_strands(store);
if (selected_elts.length == 0) {
window.alert("No strands are selected, so there is nothing to export.\n"
"Please select some strands before choosing this option.");
} else {
var cloned_svg_element_with_style = get_cloned_svg_element_with_style(selected_elts);
_export_from_element(cloned_svg_element_with_style, 'selected');
}
} else
_export_from_element(elt, 'main');
}
if (action.type == actions.ExportSvgType.side || action.type == actions.ExportSvgType.both) {
var elt = document.getElementById("side-view-svg");
_export_from_element(elt, 'side');
}
} else if (action is actions.CopySelectedStandsToClipboardImage) {
List<Element> selected_elts = get_selected_strands(store);
if (selected_elts.length != 0) {
_copy_from_elements(selected_elts);
}
}
}
} else {
next(action);
}
}

_export_from_element(svg.SvgSvgElement svg_element, String filename_append) {
var cloned_svg_element_with_style = clone_and_apply_style(svg_element);
List<Element> get_selected_strands(Store<AppState> store) {
var selected_strands = store.state.ui_state.selectables_store.selected_strands;
List<Element> selected_elts = [];
if (selected_strands.length != 0) {
for (var strand in selected_strands) {
var strand_elt = document.getElementById(strand.id);
var dna_seq_elt = document.getElementById('dna-sequence-${strand.id}');
var mismatch_elts = document.getElementsByClassName('mismatch-${strand.id}');
selected_elts.addAll([strand_elt, if (dna_seq_elt != null) dna_seq_elt, ...mismatch_elts]);
}
}
return selected_elts;
}

SvgSvgElement get_cloned_svg_element_with_style(List<Element> selected_elts) {
var cloned_svg_element_with_style = SvgSvgElement()
..children = selected_elts.map(clone_and_apply_style).toList();

var serializer = new XmlSerializer();
var source = serializer.serializeToString(cloned_svg_element_with_style);
// we can't get bbox without it being added to the DOM first
document.body.append(cloned_svg_element_with_style);
var bbox = cloned_svg_element_with_style.getBBox();
cloned_svg_element_with_style.remove();

// have to add some padding to viewbox, for some reason bbox doesn't always fit it by a few pixels??
cloned_svg_element_with_style.setAttribute('viewBox',
'${bbox.x.floor() - 1} ${bbox.y.floor() - 1} ${bbox.width.ceil() + 3} ${bbox.height.ceil() + 3}');

return cloned_svg_element_with_style;
}

_export_svg(svg.SvgSvgElement svg_element, String filename_append) {
var serializer = new XmlSerializer();
var source = serializer.serializeToString(svg_element);
//clipboard.write(source);
//add name spaces.
// if(!source.match(r'/^<svg[^>]+xmlns="http\:\/\/www\.w3\.org\/2000\/svg"/)') {
// source = source.replace(/^<svg/, '<svg xmlns="http://www.w3.org/2000/svg"');
// }
// if(!source.match(/^<svg[^>]+"http\:\/\/www\.w3\.org\/1999\/xlink"/)){
// source = source.replace(/^<svg/, '<svg xmlns:xlink="http://www.w3.org/1999/xlink"');
// }
// if(!source.match(r'/^<svg[^>]+xmlns="http\:\/\/www\.w3\.org\/2000\/svg"/)') {
// source = source.replace(/^<svg/, '<svg xmlns="http://www.w3.org/2000/svg"');
// }
// if(!source.match(/^<svg[^>]+"http\:\/\/www\.w3\.org\/1999\/xlink"/)){
// source = source.replace(/^<svg/, '<svg xmlns:xlink="http://www.w3.org/1999/xlink"');
// }

//add xml declaration
// source = '<?xml version="1.1" standalone="no"?>\r\n' + source;
// source = '<?xml version="1.1" standalone="no"?>\r\n' + source;

//convert svg source to URI data scheme.
// var url = "data:image/svg+xml;charset=utf-8," + Uri.encodeComponent(source);
// var url = "data:image/svg+xml;charset=utf-8," + Uri.encodeComponent(source);

// String blob_type = "data:image/svg+xml;charset=utf-8,";

// String blob_type = "data:image/svg+xml;charset=utf-8,";
String filename = app.state.ui_state.loaded_filename;
filename = filename.substring(0, filename.lastIndexOf('.'));
filename += '_${filename_append}.svg';

util.save_file(filename, source, blob_type: util.BlobType.image);
}

_copy_from_elements(List<Element> svg_elements) {
var cloned_svg_element_with_style = get_cloned_svg_element_with_style(svg_elements);
util.copy_svg_as_png(cloned_svg_element_with_style);
}

_export_from_element(Element svg_element, String filename_append) {
var cloned_svg_element_with_style;
if (filename_append != "selected")
cloned_svg_element_with_style = clone_and_apply_style(svg_element);
else
cloned_svg_element_with_style = svg_element;
// if element is not an svg element (it can be a child element of svg e.g. groups, lines, text, etc), wrap in svg tag
if (!(svg_element is svg.SvgSvgElement))
cloned_svg_element_with_style = SvgSvgElement()..children = [cloned_svg_element_with_style];

_export_svg(cloned_svg_element_with_style, filename_append);
}

const List<String> text_styles = [
'font-size',
'font-family',
Expand Down Expand Up @@ -102,22 +172,30 @@ final relevant_styles = {
"textPath": text_styles + text_path_only_styles,
};

clone_and_apply_style(svg.SvgElement svg_elt_orig) {
svg.SvgElement svg_elt_styled = svg_elt_orig.clone(true);
clone_and_apply_style_rec(svg_elt_styled, svg_elt_orig);
Element clone_and_apply_style(Element elt_orig) {
Element elt_styled = elt_orig.clone(true);

bool selected = elt_orig.classes.contains('selected');

elt_orig.classes.remove('selected');
clone_and_apply_style_rec(elt_styled, elt_orig);

if (selected) elt_orig.classes.add('selected');

// need to get from original since it has been rendered (styled hasn't been rendered so has 0 bounding box
// also need to get from g element, not svg element, since svg element dimensions based on original
// transformation, but g element gives untransformed bounding box.
/*
var bbox_orig_g = (svg_elt_orig.children.firstWhere((e) => e is svg.GElement) as svg.GElement).getBBox();
// Adds boundary for elements located at negative svg
svg_elt_styled.setAttribute('width', '${bbox_orig_g.width + 100}');
svg_elt_styled.setAttribute('height', '${bbox_orig_g.height + 50}');
return svg_elt_styled;
*/
return elt_styled;
}

clone_and_apply_style_rec(svg.SvgElement elt_styled, svg.SvgElement elt_orig, {int depth = 0}) {
clone_and_apply_style_rec(Element elt_styled, Element elt_orig, {int depth = 0}) {
// Set<Element> children_styled_to_remove = {};
var tag_name = elt_styled.tagName;

Expand Down Expand Up @@ -165,8 +243,8 @@ clone_and_apply_style_rec(svg.SvgElement elt_styled, svg.SvgElement elt_orig, {i
if (!(children_orig[cd] is Element)) {
continue;
}
svg.SvgElement child_orig = children_orig[cd];
svg.SvgElement child_styled = children_styled[cd];
Element child_orig = children_orig[cd];
Element child_styled = children_styled[cd];
clone_and_apply_style_rec(child_styled, child_orig, depth: depth + 1);
}

Expand Down
1 change: 1 addition & 0 deletions lib/src/serializers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import 'state/domain_name_mismatch.dart';
part 'serializers.g.dart';

@SerializersFor([
CopySelectedStandsToClipboardImage,
LoadingDialogHide,
LoadingDialogShow,
ResetLocalStorage,
Expand Down
33 changes: 33 additions & 0 deletions lib/src/util.dart
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,9 @@ num sigmoid(num x) {
return 1.0 / (1.0 + exp(-x));
}

@JS()
external clipboard_write(String blob_type_string, Blob content);

@JS(constants.js_function_name_cache_svg)
external ImageElement cache_svg(String svg_elt_id);

Expand Down Expand Up @@ -1086,6 +1089,36 @@ String blob_type_to_string(BlobType blob_type) {
throw AssertionError(ASSERTION_ERROR_MESSAGE);
}

copy_svg_as_png(SvgSvgElement svg_element) async {
try {
var serializer = new XmlSerializer();
var source = serializer.serializeToString(svg_element);
var svgUrl = Url.createObjectUrlFromBlob(new Blob([source], 'image/svg+xml'));
var svgImage = new ImageElement(src: svgUrl);
document.body.append(svgImage);
svgImage.addEventListener('load', (event) async {
var canvas = new CanvasElement();
canvas.width = svg_element.viewBox.baseVal.width * 2;
canvas.height = svg_element.viewBox.baseVal.height * 2;
var canvasCtx = canvas.context2D;
canvasCtx.drawImage(svgImage, 0, 0);
var imgData = await canvas.toBlob('image/png');
clipboard_write('image/png', imgData);
svgImage.remove();

Url.revokeObjectUrl(svgUrl);
});
svgImage.src = svgUrl;

// window.navigator.clipboard.write(DataTransfer()..setData(blob_type_string, content));

} on Exception catch (e, stackTrace) {
print(stackTrace);
} on Error catch (e, stackTrace) {
print(stackTrace);
}
}

/// [and_then] is a callback to do if the file save dialog is not canceled and no other error occurs.
/// Currently, it doesn't do much good, because it is called whether the user cancels or not. But if someday
/// we get around the issues described here:
Expand Down
2 changes: 1 addition & 1 deletion lib/src/view/design_main_dna_mismatches.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class DesignMainDNAMismatchesComponent extends UiComponent2<DesignMainDNAMismatc
if (domain_components.isNotEmpty) {
mismatch_components.add((Dom.g()
..transform = transform_str
..className = 'mismatch-components-in-domain'
..className = 'mismatch-components-in-domain mismatch-${strand.id}'
..key = util.id_domain(domain))(domain_components));
}
}
Expand Down
4 changes: 3 additions & 1 deletion lib/src/view/design_main_dna_sequence.dart
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ class DesignMainDNASequenceComponent extends UiComponent2<DesignMainDNASequenceP
throw AssertionError('unrecognized substrand type: ${substrand}');
}
}
return (Dom.g()..className = 'strand-dna-sequence')(dna_sequence_elts);
return (Dom.g()
..className = 'strand-dna-sequence'
..id = 'dna-sequence-${this.props.strand.id}')(dna_sequence_elts);
}

static const classname_dna_sequence = 'dna-seq';
Expand Down
20 changes: 20 additions & 0 deletions lib/src/view/menu.dart
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,22 @@ Copy the currently selected strand(s). They can be pasted into this design,
or into another design in another browser or tab. You can also paste into
a text document to see a JSON description of the copied strand(s).'''
..disabled = !props.enable_copy)(),
(MenuDropdownItem()
..on_click = (_) {
if (props.enable_copy) {
app.dispatch(actions.CopySelectedStandsToClipboardImage());
}
}
..display = 'Copy image'
..keyboard_shortcut = 'Ctrl+I'
..tooltip = '''\
Copy a (PNG bitmap) image of the currently selected strand(s) to the system
clipboard. This image can be pasted into graphics programs such as Powerpoint
or Inkscape. Note that the bitmap image will be pixelated on zoom-in, unlike
SVG (scaled vector graphics). To retain the vector graphics in the image so
that it stays sharp on zoom-in, use the option Export-->SVG of selected strands
to save an SVG file of the selected strands.'''
..disabled = !props.enable_copy)(),
(MenuDropdownItem()
..on_click =
((_) => window.dispatchEvent(new KeyEvent('keydown', keyCode: KeyCode.V, ctrlKey: true).wrapped))
Expand Down Expand Up @@ -1079,6 +1095,10 @@ debugging, but be warned that it will be very slow to render a large number of D
..on_click = ((_) => props.dispatch(actions.ExportSvg(type: actions.ExportSvgType.main)))
..tooltip = "Export SVG figure of main view (design shown in center of screen)."
..display = 'SVG main view')(),
(MenuDropdownItem()
..on_click = ((_) => props.dispatch(actions.ExportSvg(type: actions.ExportSvgType.selected)))
..tooltip = "Export SVG figure of selected strands"
..display = 'SVG of selected strands')(),
(MenuDropdownItem()
..on_click = ((_) => app.disable_keyboard_shortcuts_while(export_dna_sequences.export_dna))
..tooltip = "Export DNA sequences of strands to a file."
Expand Down
Loading

0 comments on commit 32fbac9

Please sign in to comment.