From 19febde547a735890a379e4c975a2f3b9b6eaa0c Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Mon, 22 Feb 2021 21:40:15 +0100 Subject: [PATCH] :sparkles: Import paths as native shapes --- backend/resources/svgclean.js | 2 +- common/app/common/data.cljc | 8 + common/app/common/geom/matrix.cljc | 25 ++- common/app/common/geom/shapes.cljc | 4 +- common/app/common/pages/spec.cljc | 2 +- exporter/src/app/http/svgclean.js | 2 +- frontend/resources/locales.json | 7 + .../resources/styles/common/framework.scss | 5 + .../partials/sidebar-element-options.scss | 3 +- .../src/app/main/data/workspace/common.cljs | 22 +- .../app/main/data/workspace/svg_upload.cljs | 173 ++++++++++----- frontend/src/app/main/ui/shapes/attrs.cljs | 83 ++++--- frontend/src/app/main/ui/shapes/group.cljs | 13 +- frontend/src/app/main/ui/shapes/shape.cljs | 4 +- frontend/src/app/main/ui/shapes/svg_defs.cljs | 103 +++++++++ frontend/src/app/main/ui/shapes/svg_raw.cljs | 16 +- .../app/main/ui/workspace/shapes/path.cljs | 1 - .../app/main/ui/workspace/shapes/svg_raw.cljs | 1 + .../ui/workspace/sidebar/options/group.cljs | 10 +- .../ui/workspace/sidebar/options/path.cljs | 8 +- .../sidebar/options/rows/input_row.cljs | 5 + .../workspace/sidebar/options/svg_attrs.cljs | 53 +++++ .../ui/workspace/sidebar/options/svg_raw.cljs | 10 +- frontend/src/app/util/a2c.js | 203 ++++++++++++++++++ frontend/src/app/util/color.cljs | 5 + frontend/src/app/util/geom/path.cljs | 172 +++++++++------ frontend/src/app/util/svg.cljs | 132 ++++++++++-- vendor/svgclean/main.js | 58 +++++ 28 files changed, 921 insertions(+), 209 deletions(-) create mode 100644 frontend/src/app/main/ui/shapes/svg_defs.cljs create mode 100644 frontend/src/app/main/ui/workspace/sidebar/options/svg_attrs.cljs create mode 100644 frontend/src/app/util/a2c.js diff --git a/backend/resources/svgclean.js b/backend/resources/svgclean.js index 80adc5883..b0d7507b9 100644 --- a/backend/resources/svgclean.js +++ b/backend/resources/svgclean.js @@ -1,4 +1,4 @@ -!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).svgc=e()}}((function(){return function e(t,r,n){function i(o,s){if(!r[o]){if(!t[o]){var l="function"==typeof require&&require;if(!s&&l)return l(o,!0);if(a)return a(o,!0);var c=new Error("Cannot find module '"+o+"'");throw c.code="MODULE_NOT_FOUND",c}var u=r[o]={exports:{}};t[o][0].call(u.exports,(function(e){return i(t[o][1][e]||e)}),u,u.exports,e,t,r,n)}return r[o].exports}for(var a="function"==typeof require&&require,o=0;oe.data))}},{"./src/svgclean.js":253}],2:[function(e,t,r){"use strict";r.byteLength=function(e){var t=c(e),r=t[0],n=t[1];return 3*(r+n)/4-n},r.toByteArray=function(e){var t,r,n=c(e),o=n[0],s=n[1],l=new a(function(e,t,r){return 3*(t+r)/4-r}(0,o,s)),u=0,d=s>0?o-4:o;for(r=0;r>16&255,l[u++]=t>>8&255,l[u++]=255&t;2===s&&(t=i[e.charCodeAt(r)]<<2|i[e.charCodeAt(r+1)]>>4,l[u++]=255&t);1===s&&(t=i[e.charCodeAt(r)]<<10|i[e.charCodeAt(r+1)]<<4|i[e.charCodeAt(r+2)]>>2,l[u++]=t>>8&255,l[u++]=255&t);return l},r.fromByteArray=function(e){for(var t,r=e.length,i=r%3,a=[],o=16383,s=0,l=r-i;sl?l:s+o));1===i?(t=e[r-1],a.push(n[t>>2]+n[t<<4&63]+"==")):2===i&&(t=(e[r-2]<<8)+e[r-1],a.push(n[t>>10]+n[t>>4&63]+n[t<<2&63]+"="));return a.join("")};for(var n=[],i=[],a="undefined"!=typeof Uint8Array?Uint8Array:Array,o="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",s=0,l=o.length;s0)throw new Error("Invalid string. Length must be a multiple of 4");var r=e.indexOf("=");return-1===r&&(r=t),[r,r===t?0:4-r%4]}function u(e,t,r){for(var i,a,o=[],s=t;s>18&63]+n[a>>12&63]+n[a>>6&63]+n[63&a]);return o.join("")}i["-".charCodeAt(0)]=62,i["_".charCodeAt(0)]=63},{}],3:[function(e,t,r){t.exports={trueFunc:function(){return!0},falseFunc:function(){return!1}}},{}],4:[function(e,t,r){},{}],5:[function(e,t,r){"use strict";var n=e("safe-buffer").Buffer,i=n.isEncoding||function(e){switch((e=""+e)&&e.toLowerCase()){case"hex":case"utf8":case"utf-8":case"ascii":case"binary":case"base64":case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":case"raw":return!0;default:return!1}};function a(e){var t;switch(this.encoding=function(e){var t=function(e){if(!e)return"utf8";for(var t;;)switch(e){case"utf8":case"utf-8":return"utf8";case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return"utf16le";case"latin1":case"binary":return"latin1";case"base64":case"ascii":case"hex":return e;default:if(t)return;e=(""+e).toLowerCase(),t=!0}}(e);if("string"!=typeof t&&(n.isEncoding===i||!i(e)))throw new Error("Unknown encoding: "+e);return t||e}(e),this.encoding){case"utf16le":this.text=l,this.end=c,t=4;break;case"utf8":this.fillLast=s,t=4;break;case"base64":this.text=u,this.end=d,t=3;break;default:return this.write=p,void(this.end=m)}this.lastNeed=0,this.lastTotal=0,this.lastChar=n.allocUnsafe(t)}function o(e){return e<=127?0:e>>5==6?2:e>>4==14?3:e>>3==30?4:e>>6==2?-1:-2}function s(e){var t=this.lastTotal-this.lastNeed,r=function(e,t,r){if(128!=(192&t[0]))return e.lastNeed=0,"�";if(e.lastNeed>1&&t.length>1){if(128!=(192&t[1]))return e.lastNeed=1,"�";if(e.lastNeed>2&&t.length>2&&128!=(192&t[2]))return e.lastNeed=2,"�"}}(this,e);return void 0!==r?r:this.lastNeed<=e.length?(e.copy(this.lastChar,t,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal)):(e.copy(this.lastChar,t,0,e.length),void(this.lastNeed-=e.length))}function l(e,t){if((e.length-t)%2==0){var r=e.toString("utf16le",t);if(r){var n=r.charCodeAt(r.length-1);if(n>=55296&&n<=56319)return this.lastNeed=2,this.lastTotal=4,this.lastChar[0]=e[e.length-2],this.lastChar[1]=e[e.length-1],r.slice(0,-1)}return r}return this.lastNeed=1,this.lastTotal=2,this.lastChar[0]=e[e.length-1],e.toString("utf16le",t,e.length-1)}function c(e){var t=e&&e.length?this.write(e):"";if(this.lastNeed){var r=this.lastTotal-this.lastNeed;return t+this.lastChar.toString("utf16le",0,r)}return t}function u(e,t){var r=(e.length-t)%3;return 0===r?e.toString("base64",t):(this.lastNeed=3-r,this.lastTotal=3,1===r?this.lastChar[0]=e[e.length-1]:(this.lastChar[0]=e[e.length-2],this.lastChar[1]=e[e.length-1]),e.toString("base64",t,e.length-r))}function d(e){var t=e&&e.length?this.write(e):"";return this.lastNeed?t+this.lastChar.toString("base64",0,3-this.lastNeed):t}function p(e){return e.toString(this.encoding)}function m(e){return e&&e.length?this.write(e):""}r.StringDecoder=a,a.prototype.write=function(e){if(0===e.length)return"";var t,r;if(this.lastNeed){if(void 0===(t=this.fillLast(e)))return"";r=this.lastNeed,this.lastNeed=0}else r=0;return r=0)return i>0&&(e.lastNeed=i-1),i;if(--n=0)return i>0&&(e.lastNeed=i-2),i;if(--n=0)return i>0&&(2===i?i=0:e.lastNeed=i-3),i;return 0}(this,e,t);if(!this.lastNeed)return e.toString("utf8",t);this.lastTotal=r;var n=e.length-(r-this.lastNeed);return e.copy(this.lastChar,0,n),e.toString("utf8",t,n)},a.prototype.fillLast=function(e){if(this.lastNeed<=e.length)return e.copy(this.lastChar,this.lastTotal-this.lastNeed,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal);e.copy(this.lastChar,this.lastTotal-this.lastNeed,0,e.length),this.lastNeed-=e.length}},{"safe-buffer":6}],6:[function(e,t,r){ +!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).svgc=e()}}((function(){return function e(t,r,n){function i(o,s){if(!r[o]){if(!t[o]){var l="function"==typeof require&&require;if(!s&&l)return l(o,!0);if(a)return a(o,!0);var c=new Error("Cannot find module '"+o+"'");throw c.code="MODULE_NOT_FOUND",c}var u=r[o]={exports:{}};t[o][0].call(u.exports,(function(e){return i(t[o][1][e]||e)}),u,u.exports,e,t,r,n)}return r[o].exports}for(var a="function"==typeof require&&require,o=0;oe.data))}},{"./src/svgclean.js":253}],2:[function(e,t,r){"use strict";r.byteLength=function(e){var t=c(e),r=t[0],n=t[1];return 3*(r+n)/4-n},r.toByteArray=function(e){var t,r,n=c(e),o=n[0],s=n[1],l=new a(function(e,t,r){return 3*(t+r)/4-r}(0,o,s)),u=0,d=s>0?o-4:o;for(r=0;r>16&255,l[u++]=t>>8&255,l[u++]=255&t;2===s&&(t=i[e.charCodeAt(r)]<<2|i[e.charCodeAt(r+1)]>>4,l[u++]=255&t);1===s&&(t=i[e.charCodeAt(r)]<<10|i[e.charCodeAt(r+1)]<<4|i[e.charCodeAt(r+2)]>>2,l[u++]=t>>8&255,l[u++]=255&t);return l},r.fromByteArray=function(e){for(var t,r=e.length,i=r%3,a=[],o=16383,s=0,l=r-i;sl?l:s+o));1===i?(t=e[r-1],a.push(n[t>>2]+n[t<<4&63]+"==")):2===i&&(t=(e[r-2]<<8)+e[r-1],a.push(n[t>>10]+n[t>>4&63]+n[t<<2&63]+"="));return a.join("")};for(var n=[],i=[],a="undefined"!=typeof Uint8Array?Uint8Array:Array,o="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",s=0,l=o.length;s0)throw new Error("Invalid string. Length must be a multiple of 4");var r=e.indexOf("=");return-1===r&&(r=t),[r,r===t?0:4-r%4]}function u(e,t,r){for(var i,a,o=[],s=t;s>18&63]+n[a>>12&63]+n[a>>6&63]+n[63&a]);return o.join("")}i["-".charCodeAt(0)]=62,i["_".charCodeAt(0)]=63},{}],3:[function(e,t,r){t.exports={trueFunc:function(){return!0},falseFunc:function(){return!1}}},{}],4:[function(e,t,r){},{}],5:[function(e,t,r){"use strict";var n=e("safe-buffer").Buffer,i=n.isEncoding||function(e){switch((e=""+e)&&e.toLowerCase()){case"hex":case"utf8":case"utf-8":case"ascii":case"binary":case"base64":case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":case"raw":return!0;default:return!1}};function a(e){var t;switch(this.encoding=function(e){var t=function(e){if(!e)return"utf8";for(var t;;)switch(e){case"utf8":case"utf-8":return"utf8";case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return"utf16le";case"latin1":case"binary":return"latin1";case"base64":case"ascii":case"hex":return e;default:if(t)return;e=(""+e).toLowerCase(),t=!0}}(e);if("string"!=typeof t&&(n.isEncoding===i||!i(e)))throw new Error("Unknown encoding: "+e);return t||e}(e),this.encoding){case"utf16le":this.text=l,this.end=c,t=4;break;case"utf8":this.fillLast=s,t=4;break;case"base64":this.text=u,this.end=d,t=3;break;default:return this.write=p,void(this.end=m)}this.lastNeed=0,this.lastTotal=0,this.lastChar=n.allocUnsafe(t)}function o(e){return e<=127?0:e>>5==6?2:e>>4==14?3:e>>3==30?4:e>>6==2?-1:-2}function s(e){var t=this.lastTotal-this.lastNeed,r=function(e,t,r){if(128!=(192&t[0]))return e.lastNeed=0,"�";if(e.lastNeed>1&&t.length>1){if(128!=(192&t[1]))return e.lastNeed=1,"�";if(e.lastNeed>2&&t.length>2&&128!=(192&t[2]))return e.lastNeed=2,"�"}}(this,e);return void 0!==r?r:this.lastNeed<=e.length?(e.copy(this.lastChar,t,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal)):(e.copy(this.lastChar,t,0,e.length),void(this.lastNeed-=e.length))}function l(e,t){if((e.length-t)%2==0){var r=e.toString("utf16le",t);if(r){var n=r.charCodeAt(r.length-1);if(n>=55296&&n<=56319)return this.lastNeed=2,this.lastTotal=4,this.lastChar[0]=e[e.length-2],this.lastChar[1]=e[e.length-1],r.slice(0,-1)}return r}return this.lastNeed=1,this.lastTotal=2,this.lastChar[0]=e[e.length-1],e.toString("utf16le",t,e.length-1)}function c(e){var t=e&&e.length?this.write(e):"";if(this.lastNeed){var r=this.lastTotal-this.lastNeed;return t+this.lastChar.toString("utf16le",0,r)}return t}function u(e,t){var r=(e.length-t)%3;return 0===r?e.toString("base64",t):(this.lastNeed=3-r,this.lastTotal=3,1===r?this.lastChar[0]=e[e.length-1]:(this.lastChar[0]=e[e.length-2],this.lastChar[1]=e[e.length-1]),e.toString("base64",t,e.length-r))}function d(e){var t=e&&e.length?this.write(e):"";return this.lastNeed?t+this.lastChar.toString("base64",0,3-this.lastNeed):t}function p(e){return e.toString(this.encoding)}function m(e){return e&&e.length?this.write(e):""}r.StringDecoder=a,a.prototype.write=function(e){if(0===e.length)return"";var t,r;if(this.lastNeed){if(void 0===(t=this.fillLast(e)))return"";r=this.lastNeed,this.lastNeed=0}else r=0;return r=0)return i>0&&(e.lastNeed=i-1),i;if(--n=0)return i>0&&(e.lastNeed=i-2),i;if(--n=0)return i>0&&(2===i?i=0:e.lastNeed=i-3),i;return 0}(this,e,t);if(!this.lastNeed)return e.toString("utf8",t);this.lastTotal=r;var n=e.length-(r-this.lastNeed);return e.copy(this.lastChar,0,n),e.toString("utf8",t,n)},a.prototype.fillLast=function(e){if(this.lastNeed<=e.length)return e.copy(this.lastChar,this.lastTotal-this.lastNeed,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal);e.copy(this.lastChar,this.lastTotal-this.lastNeed,0,e.length),this.lastNeed-=e.length}},{"safe-buffer":6}],6:[function(e,t,r){ /*! safe-buffer. MIT License. Feross Aboukhadijeh */ var n=e("buffer"),i=n.Buffer;function a(e,t){for(var r in e)t[r]=e[r]}function o(e,t,r){return i(e,t,r)}i.from&&i.alloc&&i.allocUnsafe&&i.allocUnsafeSlow?t.exports=n:(a(n,r),r.Buffer=o),o.prototype=Object.create(i.prototype),a(i,o),o.from=function(e,t,r){if("number"==typeof e)throw new TypeError("Argument must not be a number");return i(e,t,r)},o.alloc=function(e,t,r){if("number"!=typeof e)throw new TypeError("Argument must be a number");var n=i(e);return void 0!==t?"string"==typeof r?n.fill(t,r):n.fill(t):n.fill(0),n},o.allocUnsafe=function(e){if("number"!=typeof e)throw new TypeError("Argument must be a number");return i(e)},o.allocUnsafeSlow=function(e){if("number"!=typeof e)throw new TypeError("Argument must be a number");return n.SlowBuffer(e)}},{buffer:7}],7:[function(e,t,r){(function(t){(function(){ /*! diff --git a/common/app/common/data.cljc b/common/app/common/data.cljc index de3e6d59b..2606fb375 100644 --- a/common/app/common/data.cljc +++ b/common/app/common/data.cljc @@ -302,6 +302,14 @@ default v)))) +(defn num-string? [v] + ;; https://stackoverflow.com/questions/175739/built-in-way-in-javascript-to-check-if-a-string-is-a-valid-number + #?(:cljs (and (string? v) + (not (js/isNaN v)) + (not (js/isNaN (parse-double v)))) + + :clj (not= (parse-double v :nan) :nan))) + (defn read-string [v] (r/read-string v)) diff --git a/common/app/common/geom/matrix.cljc b/common/app/common/geom/matrix.cljc index 69921998a..91f6ee630 100644 --- a/common/app/common/geom/matrix.cljc +++ b/common/app/common/geom/matrix.cljc @@ -11,6 +11,8 @@ (:require #?(:cljs [cljs.pprint :as pp] :clj [clojure.pprint :as pp]) + [cuerdas.core :as str] + [app.common.data :as d] [app.common.math :as mth] [app.common.geom.point :as gpt])) @@ -21,6 +23,22 @@ (toString [_] (str "matrix(" a "," b "," c "," d "," e "," f ")"))) +(defonce matrix-regex #"matrix\((.*),(.*),(.*),(.*),(.*),(.*)\)") + +(defn matrix + "Create a new matrix instance." + ([] + (Matrix. 1 0 0 1 0 0)) + ([a b c d e f] + (Matrix. a b c d e f))) + +(defn parse-matrix [mtx] + (let [[_ a b c d e f] (re-matches matrix-regex mtx)] + (->> [a b c d e f] + (map str/trim) + (map d/parse-double) + (apply matrix)))) + (defn multiply ([{m1a :a m1b :b m1c :c m1d :d m1e :e m1f :f} {m2a :a m2b :b m2c :c m2d :d m2e :e m2f :f}] @@ -46,12 +64,7 @@ [v] (instance? Matrix v)) -(defn matrix - "Create a new matrix instance." - ([] - (Matrix. 1 0 0 1 0 0)) - ([a b c d e f] - (Matrix. a b c d e f))) + (def base (matrix)) diff --git a/common/app/common/geom/shapes.cljc b/common/app/common/geom/shapes.cljc index 844664e22..1edcac312 100644 --- a/common/app/common/geom/shapes.cljc +++ b/common/app/common/geom/shapes.cljc @@ -148,10 +148,10 @@ (update-in [:selrect :x2] - x) (update-in [:selrect :y2] - y) - (d/update-when :points #(map move-point %)) + (d/update-when :points #(mapv move-point %)) (cond-> (= :path type) - (d/update-when :content #(map move-segment %)))))) + (d/update-when :content #(mapv move-segment %)))))) ;; --- Helpers diff --git a/common/app/common/pages/spec.cljc b/common/app/common/pages/spec.cljc index 2feef1d71..d99f44ef4 100644 --- a/common/app/common/pages/spec.cljc +++ b/common/app/common/pages/spec.cljc @@ -229,7 +229,7 @@ (s/def :internal.shape/stroke-color-ref-file (s/nilable uuid?)) (s/def :internal.shape/stroke-color-ref-id (s/nilable uuid?)) (s/def :internal.shape/stroke-opacity ::safe-number) -(s/def :internal.shape/stroke-style #{:solid :dotted :dashed :mixed :none}) +(s/def :internal.shape/stroke-style #{:solid :dotted :dashed :mixed :none :svg}) (s/def :internal.shape/stroke-width ::safe-number) (s/def :internal.shape/stroke-alignment #{:center :inner :outer}) (s/def :internal.shape/text-align #{"left" "right" "center" "justify"}) diff --git a/exporter/src/app/http/svgclean.js b/exporter/src/app/http/svgclean.js index 80adc5883..b0d7507b9 100644 --- a/exporter/src/app/http/svgclean.js +++ b/exporter/src/app/http/svgclean.js @@ -1,4 +1,4 @@ -!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).svgc=e()}}((function(){return function e(t,r,n){function i(o,s){if(!r[o]){if(!t[o]){var l="function"==typeof require&&require;if(!s&&l)return l(o,!0);if(a)return a(o,!0);var c=new Error("Cannot find module '"+o+"'");throw c.code="MODULE_NOT_FOUND",c}var u=r[o]={exports:{}};t[o][0].call(u.exports,(function(e){return i(t[o][1][e]||e)}),u,u.exports,e,t,r,n)}return r[o].exports}for(var a="function"==typeof require&&require,o=0;oe.data))}},{"./src/svgclean.js":253}],2:[function(e,t,r){"use strict";r.byteLength=function(e){var t=c(e),r=t[0],n=t[1];return 3*(r+n)/4-n},r.toByteArray=function(e){var t,r,n=c(e),o=n[0],s=n[1],l=new a(function(e,t,r){return 3*(t+r)/4-r}(0,o,s)),u=0,d=s>0?o-4:o;for(r=0;r>16&255,l[u++]=t>>8&255,l[u++]=255&t;2===s&&(t=i[e.charCodeAt(r)]<<2|i[e.charCodeAt(r+1)]>>4,l[u++]=255&t);1===s&&(t=i[e.charCodeAt(r)]<<10|i[e.charCodeAt(r+1)]<<4|i[e.charCodeAt(r+2)]>>2,l[u++]=t>>8&255,l[u++]=255&t);return l},r.fromByteArray=function(e){for(var t,r=e.length,i=r%3,a=[],o=16383,s=0,l=r-i;sl?l:s+o));1===i?(t=e[r-1],a.push(n[t>>2]+n[t<<4&63]+"==")):2===i&&(t=(e[r-2]<<8)+e[r-1],a.push(n[t>>10]+n[t>>4&63]+n[t<<2&63]+"="));return a.join("")};for(var n=[],i=[],a="undefined"!=typeof Uint8Array?Uint8Array:Array,o="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",s=0,l=o.length;s0)throw new Error("Invalid string. Length must be a multiple of 4");var r=e.indexOf("=");return-1===r&&(r=t),[r,r===t?0:4-r%4]}function u(e,t,r){for(var i,a,o=[],s=t;s>18&63]+n[a>>12&63]+n[a>>6&63]+n[63&a]);return o.join("")}i["-".charCodeAt(0)]=62,i["_".charCodeAt(0)]=63},{}],3:[function(e,t,r){t.exports={trueFunc:function(){return!0},falseFunc:function(){return!1}}},{}],4:[function(e,t,r){},{}],5:[function(e,t,r){"use strict";var n=e("safe-buffer").Buffer,i=n.isEncoding||function(e){switch((e=""+e)&&e.toLowerCase()){case"hex":case"utf8":case"utf-8":case"ascii":case"binary":case"base64":case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":case"raw":return!0;default:return!1}};function a(e){var t;switch(this.encoding=function(e){var t=function(e){if(!e)return"utf8";for(var t;;)switch(e){case"utf8":case"utf-8":return"utf8";case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return"utf16le";case"latin1":case"binary":return"latin1";case"base64":case"ascii":case"hex":return e;default:if(t)return;e=(""+e).toLowerCase(),t=!0}}(e);if("string"!=typeof t&&(n.isEncoding===i||!i(e)))throw new Error("Unknown encoding: "+e);return t||e}(e),this.encoding){case"utf16le":this.text=l,this.end=c,t=4;break;case"utf8":this.fillLast=s,t=4;break;case"base64":this.text=u,this.end=d,t=3;break;default:return this.write=p,void(this.end=m)}this.lastNeed=0,this.lastTotal=0,this.lastChar=n.allocUnsafe(t)}function o(e){return e<=127?0:e>>5==6?2:e>>4==14?3:e>>3==30?4:e>>6==2?-1:-2}function s(e){var t=this.lastTotal-this.lastNeed,r=function(e,t,r){if(128!=(192&t[0]))return e.lastNeed=0,"�";if(e.lastNeed>1&&t.length>1){if(128!=(192&t[1]))return e.lastNeed=1,"�";if(e.lastNeed>2&&t.length>2&&128!=(192&t[2]))return e.lastNeed=2,"�"}}(this,e);return void 0!==r?r:this.lastNeed<=e.length?(e.copy(this.lastChar,t,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal)):(e.copy(this.lastChar,t,0,e.length),void(this.lastNeed-=e.length))}function l(e,t){if((e.length-t)%2==0){var r=e.toString("utf16le",t);if(r){var n=r.charCodeAt(r.length-1);if(n>=55296&&n<=56319)return this.lastNeed=2,this.lastTotal=4,this.lastChar[0]=e[e.length-2],this.lastChar[1]=e[e.length-1],r.slice(0,-1)}return r}return this.lastNeed=1,this.lastTotal=2,this.lastChar[0]=e[e.length-1],e.toString("utf16le",t,e.length-1)}function c(e){var t=e&&e.length?this.write(e):"";if(this.lastNeed){var r=this.lastTotal-this.lastNeed;return t+this.lastChar.toString("utf16le",0,r)}return t}function u(e,t){var r=(e.length-t)%3;return 0===r?e.toString("base64",t):(this.lastNeed=3-r,this.lastTotal=3,1===r?this.lastChar[0]=e[e.length-1]:(this.lastChar[0]=e[e.length-2],this.lastChar[1]=e[e.length-1]),e.toString("base64",t,e.length-r))}function d(e){var t=e&&e.length?this.write(e):"";return this.lastNeed?t+this.lastChar.toString("base64",0,3-this.lastNeed):t}function p(e){return e.toString(this.encoding)}function m(e){return e&&e.length?this.write(e):""}r.StringDecoder=a,a.prototype.write=function(e){if(0===e.length)return"";var t,r;if(this.lastNeed){if(void 0===(t=this.fillLast(e)))return"";r=this.lastNeed,this.lastNeed=0}else r=0;return r=0)return i>0&&(e.lastNeed=i-1),i;if(--n=0)return i>0&&(e.lastNeed=i-2),i;if(--n=0)return i>0&&(2===i?i=0:e.lastNeed=i-3),i;return 0}(this,e,t);if(!this.lastNeed)return e.toString("utf8",t);this.lastTotal=r;var n=e.length-(r-this.lastNeed);return e.copy(this.lastChar,0,n),e.toString("utf8",t,n)},a.prototype.fillLast=function(e){if(this.lastNeed<=e.length)return e.copy(this.lastChar,this.lastTotal-this.lastNeed,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal);e.copy(this.lastChar,this.lastTotal-this.lastNeed,0,e.length),this.lastNeed-=e.length}},{"safe-buffer":6}],6:[function(e,t,r){ +!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).svgc=e()}}((function(){return function e(t,r,n){function i(o,s){if(!r[o]){if(!t[o]){var l="function"==typeof require&&require;if(!s&&l)return l(o,!0);if(a)return a(o,!0);var c=new Error("Cannot find module '"+o+"'");throw c.code="MODULE_NOT_FOUND",c}var u=r[o]={exports:{}};t[o][0].call(u.exports,(function(e){return i(t[o][1][e]||e)}),u,u.exports,e,t,r,n)}return r[o].exports}for(var a="function"==typeof require&&require,o=0;oe.data))}},{"./src/svgclean.js":253}],2:[function(e,t,r){"use strict";r.byteLength=function(e){var t=c(e),r=t[0],n=t[1];return 3*(r+n)/4-n},r.toByteArray=function(e){var t,r,n=c(e),o=n[0],s=n[1],l=new a(function(e,t,r){return 3*(t+r)/4-r}(0,o,s)),u=0,d=s>0?o-4:o;for(r=0;r>16&255,l[u++]=t>>8&255,l[u++]=255&t;2===s&&(t=i[e.charCodeAt(r)]<<2|i[e.charCodeAt(r+1)]>>4,l[u++]=255&t);1===s&&(t=i[e.charCodeAt(r)]<<10|i[e.charCodeAt(r+1)]<<4|i[e.charCodeAt(r+2)]>>2,l[u++]=t>>8&255,l[u++]=255&t);return l},r.fromByteArray=function(e){for(var t,r=e.length,i=r%3,a=[],o=16383,s=0,l=r-i;sl?l:s+o));1===i?(t=e[r-1],a.push(n[t>>2]+n[t<<4&63]+"==")):2===i&&(t=(e[r-2]<<8)+e[r-1],a.push(n[t>>10]+n[t>>4&63]+n[t<<2&63]+"="));return a.join("")};for(var n=[],i=[],a="undefined"!=typeof Uint8Array?Uint8Array:Array,o="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",s=0,l=o.length;s0)throw new Error("Invalid string. Length must be a multiple of 4");var r=e.indexOf("=");return-1===r&&(r=t),[r,r===t?0:4-r%4]}function u(e,t,r){for(var i,a,o=[],s=t;s>18&63]+n[a>>12&63]+n[a>>6&63]+n[63&a]);return o.join("")}i["-".charCodeAt(0)]=62,i["_".charCodeAt(0)]=63},{}],3:[function(e,t,r){t.exports={trueFunc:function(){return!0},falseFunc:function(){return!1}}},{}],4:[function(e,t,r){},{}],5:[function(e,t,r){"use strict";var n=e("safe-buffer").Buffer,i=n.isEncoding||function(e){switch((e=""+e)&&e.toLowerCase()){case"hex":case"utf8":case"utf-8":case"ascii":case"binary":case"base64":case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":case"raw":return!0;default:return!1}};function a(e){var t;switch(this.encoding=function(e){var t=function(e){if(!e)return"utf8";for(var t;;)switch(e){case"utf8":case"utf-8":return"utf8";case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return"utf16le";case"latin1":case"binary":return"latin1";case"base64":case"ascii":case"hex":return e;default:if(t)return;e=(""+e).toLowerCase(),t=!0}}(e);if("string"!=typeof t&&(n.isEncoding===i||!i(e)))throw new Error("Unknown encoding: "+e);return t||e}(e),this.encoding){case"utf16le":this.text=l,this.end=c,t=4;break;case"utf8":this.fillLast=s,t=4;break;case"base64":this.text=u,this.end=d,t=3;break;default:return this.write=p,void(this.end=m)}this.lastNeed=0,this.lastTotal=0,this.lastChar=n.allocUnsafe(t)}function o(e){return e<=127?0:e>>5==6?2:e>>4==14?3:e>>3==30?4:e>>6==2?-1:-2}function s(e){var t=this.lastTotal-this.lastNeed,r=function(e,t,r){if(128!=(192&t[0]))return e.lastNeed=0,"�";if(e.lastNeed>1&&t.length>1){if(128!=(192&t[1]))return e.lastNeed=1,"�";if(e.lastNeed>2&&t.length>2&&128!=(192&t[2]))return e.lastNeed=2,"�"}}(this,e);return void 0!==r?r:this.lastNeed<=e.length?(e.copy(this.lastChar,t,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal)):(e.copy(this.lastChar,t,0,e.length),void(this.lastNeed-=e.length))}function l(e,t){if((e.length-t)%2==0){var r=e.toString("utf16le",t);if(r){var n=r.charCodeAt(r.length-1);if(n>=55296&&n<=56319)return this.lastNeed=2,this.lastTotal=4,this.lastChar[0]=e[e.length-2],this.lastChar[1]=e[e.length-1],r.slice(0,-1)}return r}return this.lastNeed=1,this.lastTotal=2,this.lastChar[0]=e[e.length-1],e.toString("utf16le",t,e.length-1)}function c(e){var t=e&&e.length?this.write(e):"";if(this.lastNeed){var r=this.lastTotal-this.lastNeed;return t+this.lastChar.toString("utf16le",0,r)}return t}function u(e,t){var r=(e.length-t)%3;return 0===r?e.toString("base64",t):(this.lastNeed=3-r,this.lastTotal=3,1===r?this.lastChar[0]=e[e.length-1]:(this.lastChar[0]=e[e.length-2],this.lastChar[1]=e[e.length-1]),e.toString("base64",t,e.length-r))}function d(e){var t=e&&e.length?this.write(e):"";return this.lastNeed?t+this.lastChar.toString("base64",0,3-this.lastNeed):t}function p(e){return e.toString(this.encoding)}function m(e){return e&&e.length?this.write(e):""}r.StringDecoder=a,a.prototype.write=function(e){if(0===e.length)return"";var t,r;if(this.lastNeed){if(void 0===(t=this.fillLast(e)))return"";r=this.lastNeed,this.lastNeed=0}else r=0;return r=0)return i>0&&(e.lastNeed=i-1),i;if(--n=0)return i>0&&(e.lastNeed=i-2),i;if(--n=0)return i>0&&(2===i?i=0:e.lastNeed=i-3),i;return 0}(this,e,t);if(!this.lastNeed)return e.toString("utf8",t);this.lastTotal=r;var n=e.length-(r-this.lastNeed);return e.copy(this.lastChar,0,n),e.toString("utf8",t,n)},a.prototype.fillLast=function(e){if(this.lastNeed<=e.length)return e.copy(this.lastChar,this.lastTotal-this.lastNeed,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal);e.copy(this.lastChar,this.lastTotal-this.lastNeed,0,e.length),this.lastNeed-=e.length}},{"safe-buffer":6}],6:[function(e,t,r){ /*! safe-buffer. MIT License. Feross Aboukhadijeh */ var n=e("buffer"),i=n.Buffer;function a(e,t){for(var r in e)t[r]=e[r]}function o(e,t,r){return i(e,t,r)}i.from&&i.alloc&&i.allocUnsafe&&i.allocUnsafeSlow?t.exports=n:(a(n,r),r.Buffer=o),o.prototype=Object.create(i.prototype),a(i,o),o.from=function(e,t,r){if("number"==typeof e)throw new TypeError("Argument must not be a number");return i(e,t,r)},o.alloc=function(e,t,r){if("number"!=typeof e)throw new TypeError("Argument must be a number");var n=i(e);return void 0!==t?"string"==typeof r?n.fill(t,r):n.fill(t):n.fill(0),n},o.allocUnsafe=function(e){if("number"!=typeof e)throw new TypeError("Argument must be a number");return i(e)},o.allocUnsafeSlow=function(e){if("number"!=typeof e)throw new TypeError("Argument must be a number");return n.SlowBuffer(e)}},{buffer:7}],7:[function(e,t,r){(function(t){(function(){ /*! diff --git a/frontend/resources/locales.json b/frontend/resources/locales.json index 912b78ab1..b6dc8e929 100644 --- a/frontend/resources/locales.json +++ b/frontend/resources/locales.json @@ -5505,5 +5505,12 @@ "zh_cn" : "单击以闭合路径" }, "unused" : true + }, + + "workspace.sidebar.options.svg-attrs.title": { + "translations": { + "en": "Imported SVG Attributes", + "es": "Atributos del SVG Importado" + } } } diff --git a/frontend/resources/styles/common/framework.scss b/frontend/resources/styles/common/framework.scss index 064541a06..d538f3acd 100644 --- a/frontend/resources/styles/common/framework.scss +++ b/frontend/resources/styles/common/framework.scss @@ -449,6 +449,11 @@ ul.slider-dots { content: "Y"; } } + + &.large { + width: auto; + min-width: 9rem; + } } input, diff --git a/frontend/resources/styles/main/partials/sidebar-element-options.scss b/frontend/resources/styles/main/partials/sidebar-element-options.scss index dd6fb3808..86e12c59c 100644 --- a/frontend/resources/styles/main/partials/sidebar-element-options.scss +++ b/frontend/resources/styles/main/partials/sidebar-element-options.scss @@ -709,6 +709,8 @@ .element-set-content .input-row { & .element-set-subtitle { width: 5.5rem; + overflow: hidden; + text-overflow: ellipsis; } } @@ -771,7 +773,6 @@ min-width: 56px; max-height: 10rem; } - } } diff --git a/frontend/src/app/main/data/workspace/common.cljs b/frontend/src/app/main/data/workspace/common.cljs index a83b4c68f..499d25694 100644 --- a/frontend/src/app/main/data/workspace/common.cljs +++ b/frontend/src/app/main/data/workspace/common.cljs @@ -219,15 +219,19 @@ (defn generate-unique-name "A unique name generator" - [used basename] - (s/assert ::set-of-string used) - (s/assert ::us/string basename) - (let [[prefix initial] (extract-numeric-suffix basename)] - (loop [counter initial] - (let [candidate (str prefix "-" counter)] - (if (contains? used candidate) - (recur (inc counter)) - candidate))))) + ([used basename] + (generate-unique-name used basename false)) + ([used basename prefix-first?] + (s/assert ::set-of-string used) + (s/assert ::us/string basename) + (let [[prefix initial] (extract-numeric-suffix basename)] + (loop [counter initial] + (let [candidate (if (and (= 1 counter) prefix-first?) + (str prefix) + (str prefix "-" counter))] + (if (contains? used candidate) + (recur (inc counter)) + candidate)))))) ;; --- Shape attrs (Layers Sidebar) diff --git a/frontend/src/app/main/data/workspace/svg_upload.cljs b/frontend/src/app/main/data/workspace/svg_upload.cljs index 4eb41abaf..5480088ba 100644 --- a/frontend/src/app/main/data/workspace/svg_upload.cljs +++ b/frontend/src/app/main/data/workspace/svg_upload.cljs @@ -10,16 +10,19 @@ (ns app.main.data.workspace.svg-upload (:require [app.common.data :as d] - [app.util.data :as ud] + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.pages :as cp] [app.common.uuid :as uuid] [app.main.data.workspace.common :as dwc] + [app.util.color :as uc] + [app.util.data :as ud] + [app.util.geom.path :as ugp] + [app.util.svg :as usvg] [beicon.core :as rx] [cuerdas.core :as str] - [potok.core :as ptk] - [app.util.svg :as usvg] - [app.util.geom.path :as ugp])) + [potok.core :as ptk])) (defn- svg-dimensions [data] (let [width (get-in data [:attrs :width] 100) @@ -30,40 +33,75 @@ height (d/parse-integer height-str)] [width height])) -(defn tag-name [tag] - (cond (string? tag) tag - (keyword? tag) (name tag) - (nil? tag) "node" - :else (str tag))) +(defn tag->name + "Given a tag returns its layer name" + [tag] + (str "svg-" (cond (string? tag) tag + (keyword? tag) (name tag) + (nil? tag) "node" + :else (str tag)))) -(defn setup-fill [shape attrs] - (let [fill-color (or (get-in attrs [:fill]) - (get-in attrs [:style :fill]) - "#000000") - fill-opacity (ud/parse-float (or (get-in attrs [:fill-opacity]) - (get-in attrs [:style :fill-opacity]) - "1"))] - (-> shape - (assoc :fill-color fill-color) - (assoc :fill-opacity fill-opacity)))) +(defn fix-dot-number + "Fixes decimal numbers starting in dot but without leading 0" + [num-str] + (if (str/starts-with? num-str ".") + (str "0" num-str) + num-str)) -(defn setup-stroke [shape attrs] - (-> shape - (assoc :stroke-color (:stroke attrs "#000000")) - (assoc :stroke-opacity (ud/parse-float (:stroke-opacity attrs 1))) - (assoc :stroke-style :solid) - (assoc :stroke-width (ud/parse-float (:stroke-width attrs "1"))) - (assoc :stroke-alignment :center))) - -(defn add-style-attributes [shape {:keys [attrs]}] +(defn setup-fill [shape] (cond-> shape - (d/any-key? attrs :fill :fill-opacity) - (setup-fill attrs) - - (d/any-key? attrs :stroke :stroke-width :stroke-opacity) - (setup-stroke attrs))) + ;; Color present as attribute + (uc/color? (get-in shape [:svg-attrs :fill])) + (-> (update :svg-attrs dissoc :fill) + (assoc :fill-color (-> (get-in shape [:svg-attrs :fill]) + (uc/parse-color)))) -(defn create-raw-svg [name frame-id svg-data element-data] + ;; Color present as style + (uc/color? (get-in shape [:svg-attrs :style :fill])) + (-> (update-in [:svg-attrs :style] dissoc :fill) + (assoc :fill-color (-> (get-in shape [:svg-attrs :style :fill]) + (uc/parse-color)))) + + (get-in shape [:svg-attrs :fill-opacity]) + (-> (update :svg-attrs dissoc :fill-opacity) + (assoc :fill-opacity (-> (get-in shape [:svg-attrs :fill-opacity]) + (ud/parse-float)))) + + (get-in shape [:svg-attrs :style :fill-opacity]) + (-> (update :svg-attrs dissoc :fill-opacity) + (assoc :fill-opacity (-> (get-in shape [:svg-attrs :style :fill-opacity]) + (ud/parse-float)))))) + +(defonce default-stroke {:stroke-color "#000000" + :stroke-opacity 1 + :stroke-alignment :center + :stroke-style :svg}) + +(defn setup-stroke [shape] + (let [shape + (cond-> shape + (uc/color? (get-in shape [:svg-attrs :stroke])) + (-> (update :svg-attrs dissoc :stroke) + (assoc :stroke-color (get-in shape [:svg-attrs :stroke]))) + + (uc/color? (get-in shape [:svg-attrs :style :stroke])) + (-> (update-in [:svg-attrs :style] dissoc :stroke) + (assoc :stroke-color (get-in shape [:svg-attrs :style :stroke]))) + + (get-in shape [:svg-attrs :stroke-width]) + (-> (update :svg-attrs dissoc :stroke-width) + (assoc :stroke-width (-> (get-in shape [:svg-attrs :stroke-width]) + (ud/parse-float)))) + + (get-in shape [:svg-attrs :style :stroke-width]) + (-> (update-in [:svg-attrs :style] dissoc :stroke-width) + (assoc :stroke-width (-> (get-in shape [:svg-attrs :style :stroke-width]) + (ud/parse-float)))))] + (if (d/any-key? shape :stroke-color :stroke-opacity :stroke-width) + (merge default-stroke shape) + shape))) + +(defn create-raw-svg [name frame-id svg-data {:keys [attrs] :as data}] (let [{:keys [x y width height]} svg-data] (-> {:id (uuid/next) :type :svg-raw @@ -73,9 +111,10 @@ :height height :x x :y y - :root-attrs (select-keys svg-data [:width :height]) - :content (cond-> element-data - (map? element-data) (update :attrs usvg/clean-attrs))} + :content (cond-> data + (map? data) (update :attrs usvg/clean-attrs))} + (assoc :svg-attrs attrs) + (assoc :svg-viewbox (select-keys svg-data [0 0 :width :height])) (gsh/setup-selrect)))) (defn create-svg-root [frame-id svg-data] @@ -87,14 +126,20 @@ :width width :height height :x x - :y y - :attrs (-> (get svg-data :attrs) usvg/clean-attrs) - ;;:content (if (map? data) (update data :attrs usvg/clean-attrs) data) - } - (gsh/setup-selrect)))) + :y y} + (gsh/setup-selrect) + (assoc :svg-attrs (-> (:attrs svg-data) + (dissoc :viewBox :xmlns)))))) + +(defn apply-svg-transform [content transform-str] + (let [transform (gmt/parse-matrix transform-str)] + (gsh/transform-content content transform))) + +(defn create-path-shape [name frame-id svg-data {:keys [attrs] :as data}] + (let [content (cond-> (ugp/path->content (:d attrs)) + (contains? attrs :transform) + (apply-svg-transform (:transform attrs))) -(defn parse-path [name frame-id {:keys [attrs] :as data}] - (let [content (ugp/path->content (:d attrs)) selrect (gsh/content->selrect content) points (gsh/rect->points selrect)] (-> {:id (uuid/next) @@ -104,21 +149,42 @@ :content content :selrect selrect :points points} + (assoc :svg-viewbox (select-keys selrect [:x :y :width :height])) + (assoc :svg-attrs (dissoc attrs :d :transform)) + (gsh/translate-to-frame svg-data)))) - (add-style-attributes data)))) +(defn create-group [name frame-id svg-data {:keys [attrs]}] + (let [{:keys [x y width height]} svg-data] + (-> {:id (uuid/next) + :type :group + :name name + :frame-id frame-id + :x x + :y y + :width width + :height height} + (assoc :svg-attrs attrs) + (assoc :svg-viewbox (select-keys svg-data [0 0 :width :height])) + (gsh/setup-selrect)))) (defn parse-svg-element [frame-id svg-data element-data unames] - (let [{:keys [tag]} element-data - name (dwc/generate-unique-name unames (str "svg-" (tag-name tag)))] + (let [{:keys [tag attrs]} element-data + name (dwc/generate-unique-name unames (or (:id attrs) (tag->name tag)) true) + att-refs (usvg/find-attr-references attrs) + references (usvg/find-def-references (:defs svg-data) att-refs)] - (case tag - ;; :rect (parse-rect data) - ;; :path (parse-path name frame-id data) - (create-raw-svg name frame-id svg-data element-data)))) + (-> (case tag + :g (create-group name frame-id svg-data element-data) + ;; :rect (parse-rect data) + :path (create-path-shape name frame-id (gpt/negate (gpt/point svg-data)) element-data) + (create-raw-svg name frame-id svg-data element-data)) + + (assoc :svg-defs (select-keys (:defs svg-data) references)) + (setup-fill) + (setup-stroke)))) (defn add-svg-child-changes [page-id objects selected frame-id parent-id svg-data ids-mappings result [index data]] (let [[unames [rchs uchs]] result - data (update data :attrs usvg/replace-attrs-ids ids-mappings) shape (parse-svg-element frame-id svg-data data unames) shape-id (:id shape) [rch1 uch1] (dwc/add-shape-changes page-id objects selected shape) @@ -164,6 +230,9 @@ :height height :name svg-name)) + [def-nodes svg-data] (usvg/extract-defs svg-data) + svg-data (assoc svg-data :defs def-nodes) + root-shape (create-svg-root frame-id svg-data) root-id (:id root-shape) diff --git a/frontend/src/app/main/ui/shapes/attrs.cljs b/frontend/src/app/main/ui/shapes/attrs.cljs index 66c1ac4a7..1f4dde2dd 100644 --- a/frontend/src/app/main/ui/shapes/attrs.cljs +++ b/frontend/src/app/main/ui/shapes/attrs.cljs @@ -12,7 +12,8 @@ [rumext.alpha :as mf] [cuerdas.core :as str] [app.util.object :as obj] - [app.main.ui.context :as muc])) + [app.main.ui.context :as muc] + [app.util.svg :as usvg])) (defn- stroke-type->dasharray [style] @@ -74,45 +75,77 @@ attrs))) (defn add-fill [attrs shape render-id] - (let [fill-color-gradient-id (str "fill-color-gradient_" render-id)] - (cond - (:fill-color-gradient shape) - (obj/merge! attrs #js {:fill (str/format "url(#%s)" fill-color-gradient-id)}) + (let [fill-attrs (cond + (contains? shape :fill-color-gradient) + (let [fill-color-gradient-id (str "fill-color-gradient_" render-id)] + {:fill (str/format "url(#%s)" fill-color-gradient-id)}) - (and (not= :svg-raw (:type shape)) - (not (:fill-color-gradient shape))) - (obj/merge! attrs #js {:fill (or (:fill-color shape) "transparent") - :fillOpacity (:fill-opacity shape nil)}) + (contains? shape :fill-color) + {:fill (:fill-color shape)} - (and (= :svg-raw (:type shape)) - (or (:fill-opacity shape) (:fill-color shape))) - (obj/merge! attrs #js {:fill (:fill-color shape) - :fillOpacity (:fill-opacity shape nil)}) + ;; If contains svg-attrs the origin is svg. If it's not svg origin + ;; we setup the default fill as transparent (instead of black) + (not (contains? shape :svg-attrs)) + {:fill "transparent"} - :else attrs))) + :else + {}) + + fill-attrs (cond-> fill-attrs + (contains? shape :fill-opacity) + (assoc :fillOpacity (:fill-opacity shape)))] + + (obj/merge! attrs (clj->js fill-attrs)))) (defn add-stroke [attrs shape render-id] (let [stroke-style (:stroke-style shape :none) stroke-color-gradient-id (str "stroke-color-gradient_" render-id)] (if (not= stroke-style :none) - (if (:stroke-color-gradient shape) - (obj/merge! attrs - #js {:stroke (str/format "url(#%s)" stroke-color-gradient-id) - :strokeWidth (:stroke-width shape 1) - :strokeDasharray (stroke-type->dasharray stroke-style)}) - (obj/merge! attrs - #js {:stroke (:stroke-color shape nil) - :strokeWidth (:stroke-width shape 1) - :strokeOpacity (:stroke-opacity shape nil) - :strokeDasharray (stroke-type->dasharray stroke-style)})))) - attrs) + (let [stroke-attrs + (cond-> {:strokeWidth (:stroke-width shape 1)} + (:stroke-color-gradient shape) + (assoc :stroke (str/format "url(#%s)" stroke-color-gradient-id)) + + (not (:stroke-color-gradient shape)) + (assoc :stroke (:stroke-color shape nil) + :strokeOpacity (:stroke-opacity shape nil)) + + (not= stroke-style :svg) + (assoc :strokeDasharray (stroke-type->dasharray stroke-style)))] + (obj/merge! attrs (clj->js stroke-attrs))) + attrs))) + + +(defn extract-svg-attrs + [render-id svg-defs svg-attrs] + (let [replace-id (fn [id] + (if (contains? svg-defs id) + (str render-id "-" id) + id)) + svg-attrs (-> svg-attrs + (usvg/update-attr-ids replace-id) + (usvg/clean-attrs)) + + attrs (-> svg-attrs (dissoc :style) (clj->js)) + styles (-> svg-attrs (:style {}) (clj->js))] + [attrs styles])) (defn extract-style-attrs ([shape] (let [render-id (mf/use-ctx muc/render-ctx) + svg-defs (:svg-defs shape {}) + svg-attrs (:svg-attrs shape {}) + + [svg-attrs svg-styles] (mf/use-memo + (mf/deps render-id svg-defs svg-attrs) + #(extract-svg-attrs render-id svg-defs svg-attrs)) + styles (-> (obj/new) + (obj/merge! svg-styles) (add-fill shape render-id) (add-stroke shape render-id))] + (-> (obj/new) + (obj/merge! svg-attrs) (add-border-radius shape) (obj/set! "style" styles))))) diff --git a/frontend/src/app/main/ui/shapes/group.cljs b/frontend/src/app/main/ui/shapes/group.cljs index 5e00ea9e3..d7e0f72f1 100644 --- a/frontend/src/app/main/ui/shapes/group.cljs +++ b/frontend/src/app/main/ui/shapes/group.cljs @@ -9,7 +9,9 @@ (ns app.main.ui.shapes.group (:require + [app.util.object :as obj] [rumext.alpha :as mf] + [app.main.ui.shapes.attrs :as attrs] [app.main.ui.shapes.mask :refer [mask-str mask-factory]])) (defn group-shape @@ -28,12 +30,15 @@ show-mask? (and (:masked-group? shape) (not expand-mask)) mask (when show-mask? (first childs)) - childs (if show-mask? (rest childs) childs)] + childs (if show-mask? (rest childs) childs) - [:g.group - {:pointer-events pointer-events - :mask (when (and mask (not expand-mask)) (mask-str mask))} + props (-> (attrs/extract-style-attrs shape) + (obj/merge! + #js {:className "group" + :pointerEvents pointer-events + :mask (when (and mask (not expand-mask)) (mask-str mask))}))] + [:> :g props (when mask [:> render-mask #js {:frame frame :mask mask}]) diff --git a/frontend/src/app/main/ui/shapes/shape.cljs b/frontend/src/app/main/ui/shapes/shape.cljs index 08953de39..3e18f6ea0 100644 --- a/frontend/src/app/main/ui/shapes/shape.cljs +++ b/frontend/src/app/main/ui/shapes/shape.cljs @@ -9,13 +9,12 @@ (ns app.main.ui.shapes.shape (:require - [app.common.geom.shapes :as geom] [app.common.uuid :as uuid] [app.main.ui.context :as muc] [app.main.ui.shapes.filters :as filters] [app.main.ui.shapes.gradients :as grad] + [app.main.ui.shapes.svg-defs :as defs] [app.util.object :as obj] - [cuerdas.core :as str] [rumext.alpha :as mf])) (mf/defc shape-container @@ -48,6 +47,7 @@ [:& (mf/provider muc/render-ctx) {:value render-id} [:> wrapper-tag group-props [:defs + [:& defs/svg-defs {:shape shape :render-id render-id}] [:& filters/filters {:shape shape :filter-id filter-id}] [:& grad/gradient {:shape shape :attr :fill-color-gradient}] [:& grad/gradient {:shape shape :attr :stroke-color-gradient}]] diff --git a/frontend/src/app/main/ui/shapes/svg_defs.cljs b/frontend/src/app/main/ui/shapes/svg_defs.cljs new file mode 100644 index 000000000..cd2ca9e6e --- /dev/null +++ b/frontend/src/app/main/ui/shapes/svg_defs.cljs @@ -0,0 +1,103 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020-2021 UXBOX Labs SL + +(ns app.main.ui.shapes.svg-defs + (:require + [app.common.data :as d] + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] + [app.util.object :as obj] + [app.util.svg :as usvg] + [rumext.alpha :as mf])) + +(defn add-matrix [attrs transform-key transform-matrix] + (update attrs + transform-key + (fn [val] + (if val + (str transform-matrix " " val) + (str transform-matrix))))) + +(defn transform-region [attrs transform] + (let [{x-str :x y-str :y width-str :width height-str :height} attrs + data (map d/parse-double [x-str y-str width-str height-str])] + (if (every? (comp not nil?) data) + (let [[x y width height] data + p1 (-> (gpt/point x y) + (gpt/transform transform)) + p2 (-> (gpt/point (+ x width) (+ y height)) + (gpt/transform transform))] + + (assoc attrs + :x (:x p1) + :y (:y p1) + :width (- (:x p2) (:x p1)) + :height (- (:y p2) (:y p1)))) + attrs))) + +(mf/defc svg-node [{:keys [node prefix-id transform]}] + (cond + (string? node) node + + :else + (let [{:keys [tag attrs content]} node + + transform-gradient? (and (#{:linearGradient :radialGradient} tag) + (= "userSpaceOnUse" (get attrs :gradientUnits "userSpaceOnUse"))) + + transform-pattern? (and (= :pattern tag) + (every? d/num-string? [(:x attrs "0") (:y attrs "0") (:width attrs "0") (:height attrs "0")]) + (= "userSpaceOnUse" (get attrs :patternUnits "userSpaceOnUse"))) + + transform-filter? (and (= #{:filter + ;; Filter primitives. We need to remap subregions + :feBlend :feColorMatrix :feComponentTransfer :feComposite :feConvolveMatrix + :feDiffuseLighting :feDisplacementMap :feFlood :feGaussianBlur + :feImage :feMerge :feMorphology :feOffset + :feSpecularLighting :feTile :feTurbulence} tag) + (= "userSpaceOnUse" (get attrs :filterUnits "userSpaceOnUse"))) + + attrs (-> attrs + (usvg/update-attr-ids prefix-id) + (usvg/clean-attrs) + + (cond-> + transform-gradient? (add-matrix :gradientTransform transform) + transform-pattern? (add-matrix :patternTransform transform) + transform-filter? (transform-region transform))) + + [wrapper wrapper-props] (if (= tag :mask) + ["g" #js {:transform (str transform)}] + [mf/Fragment (obj/new)])] + + [:> (name tag) (clj->js attrs) + [:> wrapper wrapper-props + (for [node content] [:& svg-node {:node node + :prefix-id prefix-id + :transform transform}])]]))) + +(mf/defc svg-defs [{:keys [shape render-id]}] + (let [svg-defs (:svg-defs shape) + transform (mf/use-memo + (mf/deps shape) + #(if (= :svg-raw (:type shape)) + (gmt/matrix) + (usvg/svg-transform-matrix shape))) + + prefix-id + (fn [id] + (cond->> id + (contains? svg-defs id) (str render-id "-")))] + + (when (and svg-defs (not (empty? svg-defs))) + (for [svg-def (vals svg-defs)] + [:& svg-node {:node svg-def + :prefix-id prefix-id + :transform transform}])))) + diff --git a/frontend/src/app/main/ui/shapes/svg_raw.cljs b/frontend/src/app/main/ui/shapes/svg_raw.cljs index d47a7ff39..d8fc4829a 100644 --- a/frontend/src/app/main/ui/shapes/svg_raw.cljs +++ b/frontend/src/app/main/ui/shapes/svg_raw.cljs @@ -21,7 +21,7 @@ [rumext.alpha :as mf])) ;; Graphic tags -(defonce graphic-element? #{ :circle :ellipse :image :line :path :polygon :polyline :rect :text #_"use"}) +(defonce graphic-element? #{:circle :ellipse :image :line :path :polygon :polyline :rect :text :use}) ;; Context to store a re-mapping of the ids (def svg-ids-ctx (mf/create-context nil)) @@ -37,17 +37,11 @@ (obj/set! "style" style)))) (defn translate-shape [attrs shape] - (let [{svg-width :width svg-height :height :as root-shape} (:root-attrs shape) - {:keys [x y width height]} (:selrect shape) - transform (->> (:transform attrs "") - (str (gmt/multiply - (gmt/matrix) - (gsh/transform-matrix shape) - (gmt/translate-matrix (gpt/point x y)) - (gmt/scale-matrix (gpt/point (/ width svg-width) (/ height svg-height)))) - " "))] + (let [transform (str (usvg/svg-transform-matrix shape) + " " + (:transform attrs ""))] (cond-> attrs - (and root-shape (graphic-element? (-> shape :content :tag))) + (and (:svg-viewbox shape) (graphic-element? (-> shape :content :tag))) (assoc :transform transform)))) (mf/defc svg-root diff --git a/frontend/src/app/main/ui/workspace/shapes/path.cljs b/frontend/src/app/main/ui/workspace/shapes/path.cljs index fd9fbe173..b1935e84d 100644 --- a/frontend/src/app/main/ui/workspace/shapes/path.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/path.cljs @@ -33,7 +33,6 @@ {::mf/wrap-props false} [props] (let [shape (unchecked-get props "shape") - hover? (or (mf/deref refs/current-hover) #{}) content-modifiers-ref (pc/make-content-modifiers-ref (:id shape)) content-modifiers (mf/deref content-modifiers-ref) editing-id (mf/deref refs/selected-edition) diff --git a/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs b/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs index 4ba0c4a8e..5563f353b 100644 --- a/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs @@ -64,6 +64,7 @@ :width width :height height :fill "transparent" + :stroke "none" :on-mouse-down handle-mouse-down :on-double-click handle-double-click :on-context-menu handle-context-menu diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/group.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/group.cljs index 16687671c..82dabbf03 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/group.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/group.cljs @@ -19,7 +19,8 @@ [app.main.ui.workspace.sidebar.options.blur :refer [blur-menu]] [app.main.ui.workspace.sidebar.options.shadow :refer [shadow-menu]] [app.main.ui.workspace.sidebar.options.stroke :refer [stroke-menu]] - [app.main.ui.workspace.sidebar.options.text :as ot])) + [app.main.ui.workspace.sidebar.options.text :as ot] + [app.main.ui.workspace.sidebar.options.svg-attrs :refer [svg-attrs-menu]])) (mf/defc options {::mf/wrap [mf/memo] @@ -36,6 +37,7 @@ [blur-ids blur-values] (get-attrs [shape] objects :blur) [stroke-ids stroke-values] (get-attrs [shape] objects :stroke) [text-ids text-values] (get-attrs [shape] objects :text) + [svg-ids svg-values] [[(:id shape)] (select-keys shape [:svg-attrs])] [comp-ids comp-values] [[(:id shape)] (select-keys shape component-attrs)]] [:div.options @@ -55,6 +57,10 @@ [:& stroke-menu {:type type :ids stroke-ids :values stroke-values}]) (when-not (empty? text-ids) - [:& ot/text-menu {:type type :ids text-ids :values text-values}])])) + [:& ot/text-menu {:type type :ids text-ids :values text-values}]) + + (when-not (empty? svg-values) + [:& svg-attrs-menu {:ids svg-ids + :values svg-values}])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/path.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/path.cljs index 363385f2f..05ed93fe2 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/path.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/path.cljs @@ -15,7 +15,8 @@ [app.main.ui.workspace.sidebar.options.fill :refer [fill-attrs fill-menu]] [app.main.ui.workspace.sidebar.options.stroke :refer [stroke-attrs stroke-menu]] [app.main.ui.workspace.sidebar.options.shadow :refer [shadow-menu]] - [app.main.ui.workspace.sidebar.options.blur :refer [blur-menu]])) + [app.main.ui.workspace.sidebar.options.blur :refer [blur-menu]] + [app.main.ui.workspace.sidebar.options.svg-attrs :refer [svg-attrs-menu]])) (mf/defc options [{:keys [shape] :as props}] @@ -36,4 +37,7 @@ [:& shadow-menu {:ids ids :values (select-keys shape [:shadow])}] [:& blur-menu {:ids ids - :values (select-keys shape [:blur])}]])) + :values (select-keys shape [:blur])}] + + [:& svg-attrs-menu {:ids ids + :values (select-keys shape [:svg-attrs])}]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/input_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/input_row.cljs index c21ff370d..7b4086804 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/input_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/input_row.cljs @@ -36,6 +36,11 @@ :placeholder placeholder :on-change on-change}] + :text + [:input {:value value + :class "input-text" + :on-change on-change} ] + [:> numeric-input {:placeholder placeholder :min min :max max diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/svg_attrs.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/svg_attrs.cljs new file mode 100644 index 000000000..4fc5d5f91 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/options/svg_attrs.cljs @@ -0,0 +1,53 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.ui.workspace.sidebar.options.svg-attrs + (:require + [app.common.data :as d] + [app.main.data.workspace.common :as dwc] + [app.main.store :as st] + [app.main.ui.workspace.sidebar.options.rows.input-row :refer [input-row]] + [app.util.dom :as dom] + [app.util.i18n :refer [tr]] + [rumext.alpha :as mf])) + +(mf/defc attribute-value [{:keys [attr value on-change] :as props}] + (let [handle-change + (mf/use-callback + (mf/deps attr on-change) + (fn [event] + (on-change attr (dom/get-target-val event))))] + [:div.element-set-content + [:& input-row {:label (name attr) + :type :text + :class "large" + :value (str value) + :on-change handle-change}]])) + +(mf/defc svg-attrs-menu [{:keys [ids type values]}] + (let [handle-change + (mf/use-callback + (mf/deps ids) + (fn [attr value] + (let [update-fn + (fn [shape] (assoc-in shape [:svg-attrs attr] value))] + + (st/emit! (dwc/update-shapes ids update-fn)))))] + + (when-not (empty? (:svg-attrs values)) + [:div.element-set + [:div.element-set-title + [:span (tr "workspace.sidebar.options.svg-attrs.title")]] + + (for [[index [attr-key attr-value]] (d/enumerate (:svg-attrs values))] + [:& attribute-value {:key attr-key + :ids ids + :attr attr-key + :value attr-value + :on-change handle-change}])]))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/svg_raw.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/svg_raw.cljs index 6b024782d..a6ee9d346 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/svg_raw.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/svg_raw.cljs @@ -17,11 +17,12 @@ [app.main.ui.workspace.sidebar.options.fill :refer [fill-attrs fill-menu]] [app.main.ui.workspace.sidebar.options.stroke :refer [stroke-attrs stroke-menu]] [app.main.ui.workspace.sidebar.options.shadow :refer [shadow-menu]] - [app.main.ui.workspace.sidebar.options.blur :refer [blur-menu]])) + [app.main.ui.workspace.sidebar.options.blur :refer [blur-menu]] + [app.main.ui.workspace.sidebar.options.svg-attrs :refer [svg-attrs-menu]])) ;; This is a list of svg tags that can be grouped in shape-container ;; this allows them to have gradients, shadows and masks -(def svg-elements #{:svg :circle :ellipse :image :line :path :polygon :polyline :rect :symbol :text :textPath}) +(def svg-elements #{:svg :g :circle :ellipse :image :line :path :polygon :polyline :rect :symbol :text :textPath}) (defn hex->number [hex] 1) (defn shorthex->longhex [hex] @@ -113,4 +114,7 @@ :values (select-keys shape [:shadow])}] [:& blur-menu {:ids ids - :values (select-keys shape [:blur])}]]))) + :values (select-keys shape [:blur])}] + + [:& svg-attrs-menu {:ids ids + :values (select-keys shape [:svg-attrs])}]]))) diff --git a/frontend/src/app/util/a2c.js b/frontend/src/app/util/a2c.js new file mode 100644 index 000000000..62c3dcae9 --- /dev/null +++ b/frontend/src/app/util/a2c.js @@ -0,0 +1,203 @@ +/** + * Arc to Bezier curves transformer + * + * Is a modified and google closure complatible version of the a2c + * functions by https://github.com/fontello/svgpath + * + * @author UXBOX Labs SL + * @license MIT License + */ + +"use strict"; + +goog.provide("app.util.a2c"); + +// https://raw.githubusercontent.com/fontello/svgpath/master/lib/a2c.js +goog.scope(function() { + const self = app.util.a2c; + + var TAU = Math.PI * 2; + + /* eslint-disable space-infix-ops */ + + // Calculate an angle between two unit vectors + // + // Since we measure angle between radii of circular arcs, + // we can use simplified math (without length normalization) + // + function unit_vector_angle(ux, uy, vx, vy) { + var sign = (ux * vy - uy * vx < 0) ? -1 : 1; + var dot = ux * vx + uy * vy; + + // Add this to work with arbitrary vectors: + // dot /= Math.sqrt(ux * ux + uy * uy) * Math.sqrt(vx * vx + vy * vy); + + // rounding errors, e.g. -1.0000000000000002 can screw up this + if (dot > 1.0) { dot = 1.0; } + if (dot < -1.0) { dot = -1.0; } + + return sign * Math.acos(dot); + } + + + // Convert from endpoint to center parameterization, + // see http://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes + // + // Return [cx, cy, theta1, delta_theta] + // + function get_arc_center(x1, y1, x2, y2, fa, fs, rx, ry, sin_phi, cos_phi) { + // Step 1. + // + // Moving an ellipse so origin will be the middlepoint between our two + // points. After that, rotate it to line up ellipse axes with coordinate + // axes. + // + var x1p = cos_phi*(x1-x2)/2 + sin_phi*(y1-y2)/2; + var y1p = -sin_phi*(x1-x2)/2 + cos_phi*(y1-y2)/2; + + var rx_sq = rx * rx; + var ry_sq = ry * ry; + var x1p_sq = x1p * x1p; + var y1p_sq = y1p * y1p; + + // Step 2. + // + // Compute coordinates of the centre of this ellipse (cx', cy') + // in the new coordinate system. + // + var radicant = (rx_sq * ry_sq) - (rx_sq * y1p_sq) - (ry_sq * x1p_sq); + + if (radicant < 0) { + // due to rounding errors it might be e.g. -1.3877787807814457e-17 + radicant = 0; + } + + radicant /= (rx_sq * y1p_sq) + (ry_sq * x1p_sq); + radicant = Math.sqrt(radicant) * (fa === fs ? -1 : 1); + + var cxp = radicant * rx/ry * y1p; + var cyp = radicant * -ry/rx * x1p; + + // Step 3. + // + // Transform back to get centre coordinates (cx, cy) in the original + // coordinate system. + // + var cx = cos_phi*cxp - sin_phi*cyp + (x1+x2)/2; + var cy = sin_phi*cxp + cos_phi*cyp + (y1+y2)/2; + + // Step 4. + // + // Compute angles (theta1, delta_theta). + // + var v1x = (x1p - cxp) / rx; + var v1y = (y1p - cyp) / ry; + var v2x = (-x1p - cxp) / rx; + var v2y = (-y1p - cyp) / ry; + + var theta1 = unit_vector_angle(1, 0, v1x, v1y); + var delta_theta = unit_vector_angle(v1x, v1y, v2x, v2y); + + if (fs === 0 && delta_theta > 0) { + delta_theta -= TAU; + } + if (fs === 1 && delta_theta < 0) { + delta_theta += TAU; + } + + return [ cx, cy, theta1, delta_theta ]; + } + + // + // Approximate one unit arc segment with bézier curves, + // see http://math.stackexchange.com/questions/873224 + // + function approximate_unit_arc(theta1, delta_theta) { + var alpha = 4/3 * Math.tan(delta_theta/4); + + var x1 = Math.cos(theta1); + var y1 = Math.sin(theta1); + var x2 = Math.cos(theta1 + delta_theta); + var y2 = Math.sin(theta1 + delta_theta); + + return [ x1, y1, x1 - y1*alpha, y1 + x1*alpha, x2 + y2*alpha, y2 - x2*alpha, x2, y2 ]; + } + + function a2c(x1, y1, x2, y2, fa, fs, rx, ry, phi) { + var sin_phi = Math.sin(phi * TAU / 360); + var cos_phi = Math.cos(phi * TAU / 360); + + // Make sure radii are valid + // + var x1p = cos_phi*(x1-x2)/2 + sin_phi*(y1-y2)/2; + var y1p = -sin_phi*(x1-x2)/2 + cos_phi*(y1-y2)/2; + + if (x1p === 0 && y1p === 0) { + // we're asked to draw line to itself + return []; + } + + if (rx === 0 || ry === 0) { + // one of the radii is zero + return []; + } + + + // Compensate out-of-range radii + // + rx = Math.abs(rx); + ry = Math.abs(ry); + + var lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry); + if (lambda > 1) { + rx *= Math.sqrt(lambda); + ry *= Math.sqrt(lambda); + } + + + // Get center parameters (cx, cy, theta1, delta_theta) + // + var cc = get_arc_center(x1, y1, x2, y2, fa, fs, rx, ry, sin_phi, cos_phi); + + var result = []; + var theta1 = cc[2]; + var delta_theta = cc[3]; + + // Split an arc to multiple segments, so each segment + // will be less than τ/4 (= 90°) + // + var segments = Math.max(Math.ceil(Math.abs(delta_theta) / (TAU / 4)), 1); + delta_theta /= segments; + + for (var i = 0; i < segments; i++) { + result.push(approximate_unit_arc(theta1, delta_theta)); + theta1 += delta_theta; + } + + // We have a bezier approximation of a unit circle, + // now need to transform back to the original ellipse + // + return result.map(function (curve) { + for (var i = 0; i < curve.length; i += 2) { + var x = curve[i + 0]; + var y = curve[i + 1]; + + // scale + x *= rx; + y *= ry; + + // rotate + var xp = cos_phi*x - sin_phi*y; + var yp = sin_phi*x + cos_phi*y; + + // translate + curve[i + 0] = xp + cc[0]; + curve[i + 1] = yp + cc[1]; + } + + return curve; + }); + } + + self.a2c = a2c; +}); diff --git a/frontend/src/app/util/color.cljs b/frontend/src/app/util/color.cljs index 4c3ad3f63..e4fd537bc 100644 --- a/frontend/src/app/util/color.cljs +++ b/frontend/src/app/util/color.cljs @@ -116,6 +116,11 @@ (= id :multiple) (= file-id :multiple))) +(defn color? [^string color-str] + (and (not (nil? color-str)) + (not (empty? color-str)) + (gcolor/isValidColor color-str))) + (defn parse-color [^string color-str] (let [result (gcolor/parse color-str)] (str (.-hex ^js result)))) diff --git a/frontend/src/app/util/geom/path.cljs b/frontend/src/app/util/geom/path.cljs index 8fe7de5f1..9a5440103 100644 --- a/frontend/src/app/util/geom/path.cljs +++ b/frontend/src/app/util/geom/path.cljs @@ -9,12 +9,13 @@ (ns app.util.geom.path (:require - [cuerdas.core :as str] [app.common.data :as cd] - [app.util.data :as d] [app.common.data :as cd] [app.common.geom.point :as gpt] - [app.util.geom.path-impl-simplify :as impl-simplify])) + [app.util.a2c :refer [a2c]] + [app.util.data :as d] + [app.util.geom.path-impl-simplify :as impl-simplify] + [cuerdas.core :as str])) (defn simplify ([points] @@ -28,23 +29,42 @@ ;; Matches numbers for path values allows values like... -.01, 10, +12.22 ;; 0 and 1 are special because can refer to flags -(def num-regex #"([+-]?(([1-9]\d*(\.\d+)?)|(\.\d+)|0|1))") +(def num-regex #"[+-]?(\d+(\.\d+)?|\.\d+)") +(def flag-regex #"[01]") -(defn coord-n [size] - (re-pattern (str "(?i)[a-z]\\s*" - (->> (range size) - (map #(identity num-regex)) - (str/join "\\s+"))))) +(defn fix-dot-number [val] + (if (str/starts-with? val ".") + (str "0" val) + val)) +(defn extract-params [cmd-str extract-commands] + (loop [result [] + extract-idx 0 + current {} + remain (-> cmd-str (subs 1) (str/trim))] -(defn parse-params [cmd-str num-params] - (let [fix-starting-dot (fn [arg] (str/replace arg #"([^\d]|^)\." "$10."))] - (->> (re-seq num-regex cmd-str) - (map first) - (map fix-starting-dot) - (map d/read-string) - (partition num-params)))) + (let [[param type] (nth extract-commands extract-idx) + regex (case type + :flag flag-regex + #_:number num-regex) + match (re-find regex remain)] + + (if match + (let [value (-> match first fix-dot-number d/read-string) + remain (str/replace-first remain regex "") + current (assoc current param value) + extract-idx (inc extract-idx) + [result current extract-idx] + (if (>= extract-idx (count extract-commands)) + [(conj result current) {} 0] + [result current extract-idx])] + (recur result + extract-idx + current + remain)) + (cond-> result + (not (empty? current)) (conj current)))))) (defn command->param-list [{:keys [command params]}] (case command @@ -73,96 +93,99 @@ (defmethod parse-command "M" [cmd] (let [relative (str/starts-with? cmd "m") - params (parse-params cmd 2)] - (for [[x y] params] + param-list (extract-params cmd [[:x :number] + [:y :number]])] + (for [params param-list] {:command :move-to :relative relative - :params {:x x :y y}}))) + :params params}))) (defmethod parse-command "Z" [cmd] [{:command :close-path}]) (defmethod parse-command "L" [cmd] (let [relative (str/starts-with? cmd "l") - params (parse-params cmd 2)] - (for [[x y] params] + param-list (extract-params cmd [[:x :number] + [:y :number]])] + (for [params param-list] {:command :line-to :relative relative - :params {:x x :y y}}))) + :params params}))) (defmethod parse-command "H" [cmd] (let [relative (str/starts-with? cmd "h") - params (parse-params cmd 1)] - (for [[value] params] + param-list (extract-params cmd [[:value :number]])] + (for [params param-list] {:command :line-to-horizontal :relative relative - :params {:value value}}))) + :params params}))) (defmethod parse-command "V" [cmd] (let [relative (str/starts-with? cmd "v") - params (parse-params cmd 1)] - (for [[value] params] + param-list (extract-params cmd [[:value :number]])] + (for [params param-list] {:command :line-to-vertical :relative relative - :params {:value value}}))) + :params params}))) (defmethod parse-command "C" [cmd] (let [relative (str/starts-with? cmd "c") - params (parse-params cmd 6)] - (for [[c1x c1y c2x c2y x y] params] + param-list (extract-params cmd [[:c1x :number] + [:c1y :number] + [:c2x :number] + [:c2y :number] + [:x :number] + [:y :number]]) + ] + (for [params param-list] {:command :curve-to :relative relative - :params {:c1x c1x - :c1y c1y - :c2x c2x - :c2y c2y - :x x - :y y}}))) + :params params}))) (defmethod parse-command "S" [cmd] (let [relative (str/starts-with? cmd "s") - params (parse-params cmd 4)] - (for [[cx cy x y] params] + param-list (extract-params cmd [[:c1x :number] + [:c2y :number] + [:x :number] + [:y :number]])] + (for [params param-list] {:command :smooth-curve-to :relative relative - :params {:cx cx - :cy cy - :x x - :y y}}))) + :params params}))) (defmethod parse-command "Q" [cmd] (let [relative (str/starts-with? cmd "s") - params (parse-params cmd 4)] - (for [[cx cy x y] params] + param-list (extract-params cmd [[:c1x :number] + [:c1y :number] + [:x :number] + [:y :number]])] + (for [params param-list] {:command :quadratic-bezier-curve-to :relative relative - :params {:cx cx - :cy cy - :x x - :y y}}))) + :params params}))) (defmethod parse-command "T" [cmd] (let [relative (str/starts-with? cmd "t") - params (parse-params cmd (coord-n 2))] - (for [[cx cy x y] params] + param-list (extract-params cmd [[:x :number] + [:y :number]])] + (for [params param-list] {:command :smooth-quadratic-bezier-curve-to :relative relative - :params {:x x - :y y}}))) + :params params}))) (defmethod parse-command "A" [cmd] (let [relative (str/starts-with? cmd "a") - params (parse-params cmd 7)] - (for [[rx ry x-axis-rotation large-arc-flag sweep-flag x y] params] + param-list (extract-params cmd [[:rx :number] + [:ry :number] + [:x-axis-rotation :number] + [:large-arc-flag :flag] + [:sweep-flag :flag] + [:x :number] + [:y :number]])] + (for [params param-list] {:command :elliptical-arc :relative relative - :params {:rx rx - :ry ry - :x-axis-rotation x-axis-rotation - :large-arc-flag large-arc-flag - :sweep-flag sweep-flag - :x x - :y y}}))) + :params params}))) (defn command->string [{:keys [command relative params] :as entry}] (let [command-str (case command @@ -185,6 +208,21 @@ (contains? params :y)) (gpt/point params))) +(defn arc->beziers [prev command] + (let [to-command + (fn [[_ _ c1x c1y c2x c2y x y]] + {:command :curve-to + :relative (:relative command) + :params {:c1x c1x :c1y c1y + :c2x c2x :c2y c2y + :x x :y y}}) + + {from-x :x from-y :y} (:params prev) + {:keys [rx ry x-axis-rotation large-arc-flag sweep-flag x y]} (:params command) + result (a2c from-x from-y x y large-arc-flag sweep-flag rx ry x-axis-rotation)] + + (mapv to-command result))) + (defn simplify-commands "Removes some commands and convert relative to absolute coordinates" [commands] @@ -208,11 +246,13 @@ (:relative command) (-> (assoc :relative false) (cd/update-in-when [:params :x] + (:x pos)) - (cd/update-in-when [:params :y] + (:y pos))) - - - )] - [(cmd-pos command) (conj result command)])) + (cd/update-in-when [:params :y] + (:y pos)))) + + result #_(conj result command) + (if (= :elliptical-arc (:command command)) + (cd/concat result (arc->beziers prev command)) + (conj result command))] + [(cmd-pos command) result])) start (first commands) start-pos (cmd-pos start)] diff --git a/frontend/src/app/util/svg.cljs b/frontend/src/app/util/svg.cljs index 197130dbc..5e77c4529 100644 --- a/frontend/src/app/util/svg.cljs +++ b/frontend/src/app/util/svg.cljs @@ -11,9 +11,16 @@ (:require [app.common.uuid :as uuid] [app.common.data :as cd] + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] [cuerdas.core :as str])) -(defonce replace-regex #"[^#]*#([^)\s]+).*") +(defonce replace-regex #"#([^\W]+)") + +(defn extract-ids [val] + (->> (re-seq replace-regex val) + (mapv second))) (defn clean-attrs "Transforms attributes to their react equivalent" @@ -36,34 +43,39 @@ (into {}))) (map-fn [[key val]] - (cond - (= key :class) [:className val] - (and (= key :style) (string? val)) [key (format-styles val)] - :else (vector (transform-key key) val)))] + (let [key (keyword key)] + (cond + (= key :class) [:className val] + (and (= key :style) (string? val)) [key (format-styles val)] + :else (vector (transform-key key) val))))] (->> attrs (map map-fn) (into {})))) +(defn update-attr-ids + "Replaces the ids inside a property" + [attrs replace-fn] + (letfn [(update-ids [key val] + (cond + (map? val) + (cd/mapm update-ids val) + + (= key :id) + (replace-fn val) + + :else + (let [replace-id + (fn [result it] + (str/replace result it (replace-fn it)))] + (reduce replace-id val (extract-ids val)))))] + (cd/mapm update-ids attrs))) + (defn replace-attrs-ids "Replaces the ids inside a property" [attrs ids-mapping] (if (and ids-mapping (not (empty? ids-mapping))) - (letfn [(replace-ids [key val] - (cond - (map? val) - (cd/mapm replace-ids val) - - (and (= key :id) (contains? ids-mapping val)) - (get ids-mapping val) - - :else - (let [[_ from-id] (re-matches replace-regex val)] - (if (and from-id (contains? ids-mapping from-id)) - (str/replace val from-id (get ids-mapping from-id)) - val))))] - (cd/mapm replace-ids attrs)) - + (update-attr-ids attrs (fn [id] (get ids-mapping id id))) ;; Ids-mapping is null attrs)) @@ -74,3 +86,83 @@ element-id (assoc element-id (str (uuid/next))))] (reduce visit-node result (:content node))))] (visit-node {} content))) + +(defn extract-defs [{:keys [tag content] :as node}] + + (if-not (map? node) + [{} node] + (letfn [(def-tag? [{:keys [tag]}] (= tag :defs)) + + (assoc-node [result node] + (assoc result (-> node :attrs :id) node)) + + (node-data [node] + (->> (:content node) (reduce assoc-node {})))] + + (let [current-def (->> content + (filterv def-tag?) + (map node-data) + (reduce merge)) + result (->> content + (filter (comp not def-tag?)) + (map extract-defs)) + + current-def (->> result (map first) (reduce merge current-def)) + content (->> result (mapv second))] + + [current-def (assoc node :content content)])))) + +(defn find-attr-references [attrs] + (->> attrs + (mapcat (fn [[_ attr-value]] (extract-ids attr-value))))) + +(defn find-node-references [node] + (let [current (->> (find-attr-references (:attrs node)) (into #{})) + children (->> (:content node) (map find-node-references) (flatten) (into #{}))] + (-> (cd/concat current children) + (vec)))) + +(defn find-def-references [defs references] + (loop [result (into #{} references) + checked? #{} + to-check (first references) + pending (rest references)] + + (cond + (nil? to-check) + result + + (checked? to-check) + (recur result + checked? + (first pending) + (rest pending)) + + :else + (let [node (get defs to-check) + new-refs (find-node-references node)] + (recur (cd/concat result new-refs) + (conj checked? to-check) + (first pending) + (rest pending)))))) + +(defn svg-transform-matrix [shape] + (if (:svg-viewbox shape) + (let [{svg-x :x + svg-y :y + svg-width :width + svg-height :height} (:svg-viewbox shape) + {:keys [x y width height]} (:selrect shape) + + scale-x (/ width svg-width) + scale-y (/ height svg-height)] + + (gmt/multiply + (gmt/matrix) + (gsh/transform-matrix shape) + (gmt/translate-matrix (gpt/point (- x (* scale-x svg-x)) (- y (* scale-y svg-y)))) + (gmt/scale-matrix (gpt/point scale-x scale-y)))) + + ;; :else + (gmt/matrix))) + diff --git a/vendor/svgclean/main.js b/vendor/svgclean/main.js index 60ae9479c..e9009ea74 100644 --- a/vendor/svgclean/main.js +++ b/vendor/svgclean/main.js @@ -1,3 +1,4 @@ +/* const plugins = [ {removeDimensions: true}, {removeScriptElement: true}, @@ -11,6 +12,63 @@ const plugins = [ forceAbsolutePath: true, }} ]; +*/ + +const plugins = [ + 'removeDoctype', + 'removeXMLProcInst', + 'removeComments', + 'removeMetadata', + // 'removeXMLNS', + 'removeEditorsNSData', + 'cleanupAttrs', + 'inlineStyles', + 'minifyStyles', + // 'convertStyleToAttrs' + 'cleanupIDs', + // 'prefixIds', + // 'removeRasterImages', + // 'removeUselessDefs', + 'cleanupNumericValues', + // 'cleanupListOfValues', + 'convertColors', + 'removeUnknownsAndDefaults', + 'removeNonInheritableGroupAttrs', + 'removeUselessStrokeAndFill', + // 'removeViewBox', + 'cleanupEnableBackground', + 'removeHiddenElems', + 'removeEmptyText', + 'convertShapeToPath', + 'convertEllipseToCircle', + // 'moveElemsAttrsToGroup', + 'moveGroupAttrsToElems', + 'collapseGroups', + {'convertPathData': { + 'lineShorthands': false, + 'curveSmoothShorthands': false, + 'forceAbsolutePath': true, + }}, + 'convertTransform', + 'removeEmptyAttrs', + 'removeEmptyContainers', + 'mergePaths', + 'removeUnusedNS', + // 'sortAttrs', + 'sortDefsChildren', + 'removeTitle', + 'removeDesc', + 'removeDimensions', + 'removeAttrs', + // 'removeAttributesBySelector', + // 'removeElementsByAttr', + // 'addClassesToSVGElement', + 'removeStyleElement', + 'removeScriptElement', + // 'addAttributesToSVGElement', + // 'removeOffCanvasPaths', + // 'reusePaths', +]; const svgc = require("./src/svgclean.js"); const inst = svgc.configure({plugins});