From 267fb2a0234ee2034a22d78b29398d17e8820acc Mon Sep 17 00:00:00 2001 From: mattuy Date: Wed, 6 Oct 2021 16:06:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 233 +------ src/menu/editor.js | 55 +- src/store/history.js | 7 + src/store/state.js | 6 + src/util/dom-to-image.js | 799 +++++++++++++++++++++++++ src/util/file.js | 61 ++ src/view/Canvas/index.js | 13 + src/view/Header/LeftInfoBlock/index.js | 1 + 8 files changed, 950 insertions(+), 225 deletions(-) create mode 100644 src/util/dom-to-image.js create mode 100644 src/util/file.js diff --git a/package-lock.json b/package-lock.json index ade733b..ce2aa6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,13 +13,12 @@ "@testing-library/react": "^11.2.7", "@testing-library/user-event": "^12.8.3", "antd": "^4.16.13", - "bootstrap": "^4.6.0", + "dom-to-image": "^2.6.0", "mobx": "^6.3.3", "mobx-react-lite": "^3.2.1", "react": "^17.0.2", "react-dom": "^17.0.2", "react-scripts": "4.0.3", - "reactstrap": "^8.10.0", "web-vitals": "^1.1.2" } }, @@ -1930,19 +1929,6 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz", "integrity": "sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==" }, - "node_modules/@hypnosphi/create-react-context": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@hypnosphi/create-react-context/-/create-react-context-0.3.1.tgz", - "integrity": "sha512-V1klUed202XahrWJLLOT3EXNeCpFHCcJntdFGI15ntCwau+jfT386w7OFTMaCqOgXUH1fa0w/I1oZs+i/Rfr0A==", - "dependencies": { - "gud": "^1.0.0", - "warning": "^4.0.3" - }, - "peerDependencies": { - "prop-types": "^15.0.0", - "react": ">=0.14.0" - } - }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -5543,19 +5529,6 @@ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=" }, - "node_modules/bootstrap": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.0.tgz", - "integrity": "sha512-Io55IuQY3kydzHtbGvQya3H+KorS/M9rSNyfCGCg9WZ4pyT/lCxIlpJgG1GXW/PswzC84Tr2fBYi+7+jFVQQBw==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/bootstrap" - }, - "peerDependencies": { - "jquery": "1.9.1 - 3", - "popper.js": "^1.16.1" - } - }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -7464,14 +7437,6 @@ "utila": "~0.4" } }, - "node_modules/dom-helpers": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", - "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", - "dependencies": { - "@babel/runtime": "^7.1.2" - } - }, "node_modules/dom-serializer": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.2.tgz", @@ -7485,6 +7450,11 @@ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, + "node_modules/dom-to-image": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/dom-to-image/-/dom-to-image-2.6.0.tgz", + "integrity": "sha1-ilA2CAiMh7HCL5A0rgMuGJiVWGc=" + }, "node_modules/domain-browser": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz", @@ -9846,11 +9816,6 @@ "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", "optional": true }, - "node_modules/gud": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz", - "integrity": "sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw==" - }, "node_modules/gzip-size": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-5.1.1.tgz", @@ -13131,12 +13096,6 @@ "node": ">=8" } }, - "node_modules/jquery": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.0.tgz", - "integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==", - "peer": true - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -15050,16 +15009,6 @@ "node": ">=6" } }, - "node_modules/popper.js": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", - "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==", - "deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, "node_modules/portfinder": { "version": "1.0.28", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz", @@ -17411,28 +17360,6 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, - "node_modules/react-lifecycles-compat": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" - }, - "node_modules/react-popper": { - "version": "1.3.11", - "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.11.tgz", - "integrity": "sha512-VSA/bS+pSndSF2fiasHK/PTEEAyOpX60+H5EPAjoArr8JGm+oihu4UbrqcEBpQibJxBVCpYyjAX7abJ+7DoYVg==", - "dependencies": { - "@babel/runtime": "^7.1.2", - "@hypnosphi/create-react-context": "^0.3.1", - "deep-equal": "^1.1.1", - "popper.js": "^1.14.4", - "prop-types": "^15.6.1", - "typed-styles": "^0.0.7", - "warning": "^4.0.2" - }, - "peerDependencies": { - "react": "0.14.x || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, "node_modules/react-refresh": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.8.3.tgz", @@ -17562,48 +17489,6 @@ "semver": "bin/semver" } }, - "node_modules/react-transition-group": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz", - "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==", - "dependencies": { - "dom-helpers": "^3.4.0", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2", - "react-lifecycles-compat": "^3.0.4" - }, - "peerDependencies": { - "react": ">=15.0.0", - "react-dom": ">=15.0.0" - } - }, - "node_modules/reactstrap": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/reactstrap/-/reactstrap-8.10.0.tgz", - "integrity": "sha512-MsFUB/fRZj6Orf8Mxc93iYuAs+9ngnFmy2cfYlzkmc4vi5oM4u6ziY/DsO71lDG3cotxHRyS3Flr51cuYv+IEQ==", - "dependencies": { - "@babel/runtime": "^7.12.5", - "classnames": "^2.2.3", - "prop-types": "^15.5.8", - "react-popper": "^1.3.6", - "react-transition-group": "^2.3.1" - }, - "peerDependencies": { - "react": ">=16.3.0", - "react-dom": ">=16.3.0" - } - }, - "node_modules/reactstrap/node_modules/@babel/runtime": { - "version": "7.15.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.15.4.tgz", - "integrity": "sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw==", - "dependencies": { - "regenerator-runtime": "^0.13.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/read-pkg": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", @@ -20495,11 +20380,6 @@ "node": ">= 0.6" } }, - "node_modules/typed-styles": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/typed-styles/-/typed-styles-0.0.7.tgz", - "integrity": "sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q==" - }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -24085,15 +23965,6 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz", "integrity": "sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==" }, - "@hypnosphi/create-react-context": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@hypnosphi/create-react-context/-/create-react-context-0.3.1.tgz", - "integrity": "sha512-V1klUed202XahrWJLLOT3EXNeCpFHCcJntdFGI15ntCwau+jfT386w7OFTMaCqOgXUH1fa0w/I1oZs+i/Rfr0A==", - "requires": { - "gud": "^1.0.0", - "warning": "^4.0.3" - } - }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -26850,12 +26721,6 @@ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=" }, - "bootstrap": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.0.tgz", - "integrity": "sha512-Io55IuQY3kydzHtbGvQya3H+KorS/M9rSNyfCGCg9WZ4pyT/lCxIlpJgG1GXW/PswzC84Tr2fBYi+7+jFVQQBw==", - "requires": {} - }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -28380,14 +28245,6 @@ "utila": "~0.4" } }, - "dom-helpers": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", - "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", - "requires": { - "@babel/runtime": "^7.1.2" - } - }, "dom-serializer": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.2.tgz", @@ -28398,6 +28255,11 @@ "entities": "^2.0.0" } }, + "dom-to-image": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/dom-to-image/-/dom-to-image-2.6.0.tgz", + "integrity": "sha1-ilA2CAiMh7HCL5A0rgMuGJiVWGc=" + }, "domain-browser": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz", @@ -30172,11 +30034,6 @@ "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", "optional": true }, - "gud": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz", - "integrity": "sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw==" - }, "gzip-size": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-5.1.1.tgz", @@ -32587,12 +32444,6 @@ } } }, - "jquery": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.0.tgz", - "integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==", - "peer": true - }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -34063,11 +33914,6 @@ "ts-pnp": "^1.1.6" } }, - "popper.js": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", - "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==" - }, "portfinder": { "version": "1.0.28", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz", @@ -35914,25 +35760,6 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, - "react-lifecycles-compat": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" - }, - "react-popper": { - "version": "1.3.11", - "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.11.tgz", - "integrity": "sha512-VSA/bS+pSndSF2fiasHK/PTEEAyOpX60+H5EPAjoArr8JGm+oihu4UbrqcEBpQibJxBVCpYyjAX7abJ+7DoYVg==", - "requires": { - "@babel/runtime": "^7.1.2", - "@hypnosphi/create-react-context": "^0.3.1", - "deep-equal": "^1.1.1", - "popper.js": "^1.14.4", - "prop-types": "^15.6.1", - "typed-styles": "^0.0.7", - "warning": "^4.0.2" - } - }, "react-refresh": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.8.3.tgz", @@ -36036,39 +35863,6 @@ } } }, - "react-transition-group": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz", - "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==", - "requires": { - "dom-helpers": "^3.4.0", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2", - "react-lifecycles-compat": "^3.0.4" - } - }, - "reactstrap": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/reactstrap/-/reactstrap-8.10.0.tgz", - "integrity": "sha512-MsFUB/fRZj6Orf8Mxc93iYuAs+9ngnFmy2cfYlzkmc4vi5oM4u6ziY/DsO71lDG3cotxHRyS3Flr51cuYv+IEQ==", - "requires": { - "@babel/runtime": "^7.12.5", - "classnames": "^2.2.3", - "prop-types": "^15.5.8", - "react-popper": "^1.3.6", - "react-transition-group": "^2.3.1" - }, - "dependencies": { - "@babel/runtime": { - "version": "7.15.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.15.4.tgz", - "integrity": "sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw==", - "requires": { - "regenerator-runtime": "^0.13.4" - } - } - } - }, "read-pkg": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", @@ -38362,11 +38156,6 @@ "mime-types": "~2.1.24" } }, - "typed-styles": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/typed-styles/-/typed-styles-0.0.7.tgz", - "integrity": "sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q==" - }, "typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", diff --git a/src/menu/editor.js b/src/menu/editor.js index ad69eaf..5f935fd 100644 --- a/src/menu/editor.js +++ b/src/menu/editor.js @@ -1,3 +1,4 @@ +import { Button, Menu, Upload } from "antd"; import { FolderOpenOutlined, HighlightOutlined, @@ -7,16 +8,32 @@ import { SaveOutlined, UndoOutlined, } from "@ant-design/icons"; -import { Menu } from "antd"; import state from "../store/state"; import store from "../store/global"; import { createNotation, isNote, notations as N } from "../util/notation"; import { createParagraph } from "../util/paragraph"; +import { exportFile, loadFile, saveFile } from "../util/file"; import { findParagraphAndNotation } from "../util/editor"; -import { go, runInWrappedAction, wrappedAction } from "../store/history"; +import { + go, + runInWrappedAction, + unwrappedAction, + wrappedAction, +} from "../store/history"; const { SubMenu } = Menu; +const handleOpenFile = unwrappedAction((ev) => { + // TODO: working + const file = ev.target.files[0]; + loadFile(file); +}); +const handleSaveFile = unwrappedAction(() => { + saveFile(); +}); +const handleExportFile = () => { + exportFile(); +}; const handleEditMenu = wrappedAction(({ key }) => { switch (key) { case "undo": @@ -221,6 +238,20 @@ const handleKeyPress = wrappedAction((ev) => { case inputKey === "z" && ctrl && shift: go(1); break; + case inputKey === "s" && ctrl && !shift: { + ev.preventDefault(); + saveFile(); + break; + } + case inputKey === "o" && ctrl && !shift: { + ev.preventDefault(); + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".json"; + input.onchange = handleOpenFile; + input.click(); + break; + } case inputKey === "?" && !ctrl: { if (state.helpDialogVisible) { state.helpDialogVisible = false; @@ -240,10 +271,28 @@ const fileMenu = ( }> 打开 + - }> + } onClick={handleSaveFile}> 保存 + } onClick={handleExportFile}> + 导出为图片 + ); const editMenu = ( diff --git a/src/store/history.js b/src/store/history.js index 7b02445..419a4ec 100644 --- a/src/store/history.js +++ b/src/store/history.js @@ -52,6 +52,11 @@ function go(span) { Object.assign(globalStore, storeHistory[newCursor]); } +function clearHistory() { + storeHistory.length = 0; + cursor = -1; +} + // 包装mobx的action以实现历史记录 function executeAction(actionFunc, ...args) { actionFunc.apply(this, args); @@ -88,12 +93,14 @@ function runInWrappedAction(func) { executeAction.call(this, func); }); } + // 不需要记录历史记录的时候直接使用原action const unwrappedAction = action; const runInUnwrappedAction = runInAction; export { go, + clearHistory, wrappedAction, runInWrappedAction, unwrappedAction, diff --git a/src/store/state.js b/src/store/state.js index b766c19..003e039 100644 --- a/src/store/state.js +++ b/src/store/state.js @@ -14,6 +14,12 @@ let globalState = observable({ helpDialogVisible: false, // 配置项弹窗是否可见 configDialogVisible: false, + // 持久化的文件 + file: null, + // ref + refs: { + svg: null, + }, }); export default globalState; diff --git a/src/util/dom-to-image.js b/src/util/dom-to-image.js new file mode 100644 index 0000000..ddf2d44 --- /dev/null +++ b/src/util/dom-to-image.js @@ -0,0 +1,799 @@ +// COPIED FROM: https://github.com/tsayen/dom-to-image +// 由于原仓库不支持高dpi改良了一下。上游合并和可引入库依赖 + +var util = newUtil(); +var inliner = newInliner(); +var fontFaces = newFontFaces(); +var images = newImages(); +var dpi = window.devicePixelRatio || 1; + +// Default impl options +var defaultOptions = { + // Default is to fail on error, no placeholder + imagePlaceholder: undefined, + // Default cache bust is false, it will use the cache + cacheBust: false, +}; + +var domtoimage = { + toSvg: toSvg, + toPng: toPng, + toJpeg: toJpeg, + toBlob: toBlob, + toPixelData: toPixelData, + impl: { + fontFaces: fontFaces, + images: images, + util: util, + inliner: inliner, + options: {}, + }, +}; + +/** + * @param {Node} node - The DOM Node object to render + * @param {Object} options - Rendering options + * @param {Function} options.filter - Should return true if passed node should be included in the output + * (excluding node means excluding it's children as well). Not called on the root node. + * @param {String} options.bgcolor - color for the background, any valid CSS color value. + * @param {Number} options.width - width to be applied to node before rendering. + * @param {Number} options.height - height to be applied to node before rendering. + * @param {Object} options.style - an object whose properties to be copied to node's style before rendering. + * @param {Number} options.quality - a Number between 0 and 1 indicating image quality (applicable to JPEG only), + defaults to 1.0. + * @param {String} options.imagePlaceholder - dataURL to use as a placeholder for failed images, default behaviour is to fail fast on images we can't fetch + * @param {Boolean} options.cacheBust - set to true to cache bust by appending the time to the request url + * @return {Promise} - A promise that is fulfilled with a SVG image data URL + * */ +function toSvg(node, options) { + options = options || {}; + copyOptions(options); + return Promise.resolve(node) + .then(function (node) { + return cloneNode(node, options.filter, true); + }) + .then(embedFonts) + .then(inlineImages) + .then(applyOptions) + .then(function (clone) { + return makeSvgDataUri( + clone, + (options.width || util.width(node)) * dpi, + options.height || util.height(node) * dpi + ); + }); + + function applyOptions(clone) { + if (options.bgcolor) clone.style.backgroundColor = options.bgcolor; + + if (options.width) clone.style.width = options.width + "px"; + if (options.height) clone.style.height = options.height + "px"; + + if (options.style) + Object.keys(options.style).forEach(function (property) { + clone.style[property] = options.style[property]; + }); + + return clone; + } +} + +/** + * @param {Node} node - The DOM Node object to render + * @param {Object} options - Rendering options, @see {@link toSvg} + * @return {Promise} - A promise that is fulfilled with a Uint8Array containing RGBA pixel data. + * */ +function toPixelData(node, options) { + return draw(node, options || {}).then(function (canvas) { + return canvas + .getContext("2d") + .getImageData(0, 0, util.width(node) * dpi, util.height(node) * dpi).data; + }); +} + +/** + * @param {Node} node - The DOM Node object to render + * @param {Object} options - Rendering options, @see {@link toSvg} + * @return {Promise} - A promise that is fulfilled with a PNG image data URL + * */ +function toPng(node, options) { + return draw(node, options || {}).then(function (canvas) { + return canvas.toDataURL(); + }); +} + +/** + * @param {Node} node - The DOM Node object to render + * @param {Object} options - Rendering options, @see {@link toSvg} + * @return {Promise} - A promise that is fulfilled with a JPEG image data URL + * */ +function toJpeg(node, options) { + options = options || {}; + return draw(node, options).then(function (canvas) { + return canvas.toDataURL("image/jpeg", options.quality || 1.0); + }); +} + +/** + * @param {Node} node - The DOM Node object to render + * @param {Object} options - Rendering options, @see {@link toSvg} + * @return {Promise} - A promise that is fulfilled with a PNG image blob + * */ +function toBlob(node, options) { + return draw(node, options || {}).then(util.canvasToBlob); +} + +function copyOptions(options) { + // Copy options to impl options for use in impl + if (typeof options.imagePlaceholder === "undefined") { + domtoimage.impl.options.imagePlaceholder = defaultOptions.imagePlaceholder; + } else { + domtoimage.impl.options.imagePlaceholder = options.imagePlaceholder; + } + + if (typeof options.cacheBust === "undefined") { + domtoimage.impl.options.cacheBust = defaultOptions.cacheBust; + } else { + domtoimage.impl.options.cacheBust = options.cacheBust; + } +} + +function draw(domNode, options) { + return toSvg(domNode, options) + .then(util.makeImage) + .then(util.delay(100)) + .then(function (image) { + var canvas = newCanvas(domNode); + canvas.getContext("2d").drawImage(image, 0, 0); + return canvas; + }); + + function newCanvas(domNode) { + var canvas = document.createElement("canvas"); + var width = options.width || util.width(domNode); + var height = options.height || util.height(domNode); + canvas.width = width * dpi; + canvas.height = height * dpi; + canvas.style.width = width + "px"; + canvas.style.height = height + "px"; + + canvas.getContext("2d").scale(dpi, dpi); + if (options.bgcolor) { + var ctx = canvas.getContext("2d"); + ctx.fillStyle = options.bgcolor; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + + return canvas; + } +} + +function cloneNode(node, filter, root) { + if (!root && filter && !filter(node)) return Promise.resolve(); + + return Promise.resolve(node) + .then(makeNodeCopy) + .then(function (clone) { + return cloneChildren(node, clone, filter); + }) + .then(function (clone) { + return processClone(node, clone); + }); + + function makeNodeCopy(node) { + if (node instanceof HTMLCanvasElement) + return util.makeImage(node.toDataURL()); + return node.cloneNode(false); + } + + function cloneChildren(original, clone, filter) { + var children = original.childNodes; + if (children.length === 0) return Promise.resolve(clone); + + return cloneChildrenInOrder(clone, util.asArray(children), filter).then( + function () { + return clone; + } + ); + + function cloneChildrenInOrder(parent, children, filter) { + var done = Promise.resolve(); + children.forEach(function (child) { + done = done + .then(function () { + return cloneNode(child, filter); + }) + .then(function (childClone) { + if (childClone) parent.appendChild(childClone); + }); + }); + return done; + } + } + + function processClone(original, clone) { + if (!(clone instanceof Element)) return clone; + + return Promise.resolve() + .then(cloneStyle) + .then(clonePseudoElements) + .then(copyUserInput) + .then(fixSvg) + .then(function () { + return clone; + }); + + function cloneStyle() { + copyStyle(window.getComputedStyle(original), clone.style); + + function copyStyle(source, target) { + if (source.cssText) target.cssText = source.cssText; + else copyProperties(source, target); + + function copyProperties(source, target) { + util.asArray(source).forEach(function (name) { + target.setProperty( + name, + source.getPropertyValue(name), + source.getPropertyPriority(name) + ); + }); + } + } + } + + function clonePseudoElements() { + [":before", ":after"].forEach(function (element) { + clonePseudoElement(element); + }); + + function clonePseudoElement(element) { + var style = window.getComputedStyle(original, element); + var content = style.getPropertyValue("content"); + + if (content === "" || content === "none") return; + + var className = util.uid(); + clone.className = clone.className + " " + className; + var styleElement = document.createElement("style"); + styleElement.appendChild( + formatPseudoElementStyle(className, element, style) + ); + clone.appendChild(styleElement); + + function formatPseudoElementStyle(className, element, style) { + var selector = "." + className + ":" + element; + var cssText = style.cssText + ? formatCssText(style) + : formatCssProperties(style); + return document.createTextNode(selector + "{" + cssText + "}"); + + function formatCssText(style) { + var content = style.getPropertyValue("content"); + return style.cssText + " content: " + content + ";"; + } + + function formatCssProperties(style) { + return util.asArray(style).map(formatProperty).join("; ") + ";"; + + function formatProperty(name) { + return ( + name + + ": " + + style.getPropertyValue(name) + + (style.getPropertyPriority(name) ? " !important" : "") + ); + } + } + } + } + } + + function copyUserInput() { + if (original instanceof HTMLTextAreaElement) + clone.innerHTML = original.value; + if (original instanceof HTMLInputElement) + clone.setAttribute("value", original.value); + } + + function fixSvg() { + if (!(clone instanceof SVGElement)) return; + clone.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + + if (!(clone instanceof SVGRectElement)) return; + ["width", "height"].forEach(function (attribute) { + var value = clone.getAttribute(attribute); + if (!value) return; + + clone.style.setProperty(attribute, value); + }); + } + } +} + +function embedFonts(node) { + return fontFaces.resolveAll().then(function (cssText) { + var styleNode = document.createElement("style"); + node.appendChild(styleNode); + styleNode.appendChild(document.createTextNode(cssText)); + return node; + }); +} + +function inlineImages(node) { + return images.inlineAll(node).then(function () { + return node; + }); +} + +function makeSvgDataUri(node, width, height) { + return Promise.resolve(node) + .then(function (node) { + node.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); + return new XMLSerializer().serializeToString(node); + }) + .then(util.escapeXhtml) + .then(function (xhtml) { + return ( + '' + + xhtml + + "" + ); + }) + .then(function (foreignObject) { + return ( + '' + + foreignObject + + "" + ); + }) + .then(function (svg) { + return "data:image/svg+xml;charset=utf-8," + svg; + }); +} + +function newUtil() { + return { + escape: escape, + parseExtension: parseExtension, + mimeType: mimeType, + dataAsUrl: dataAsUrl, + isDataUrl: isDataUrl, + canvasToBlob: canvasToBlob, + resolveUrl: resolveUrl, + getAndEncode: getAndEncode, + uid: uid(), + delay: delay, + asArray: asArray, + escapeXhtml: escapeXhtml, + makeImage: makeImage, + width: width, + height: height, + }; + + function mimes() { + /* + * Only WOFF and EOT mime types for fonts are 'real' + * see http://www.iana.org/assignments/media-types/media-types.xhtml + */ + var WOFF = "application/font-woff"; + var JPEG = "image/jpeg"; + + return { + woff: WOFF, + woff2: WOFF, + ttf: "application/font-truetype", + eot: "application/vnd.ms-fontobject", + png: "image/png", + jpg: JPEG, + jpeg: JPEG, + gif: "image/gif", + tiff: "image/tiff", + svg: "image/svg+xml", + }; + } + + function parseExtension(url) { + var match = /\.([^\.\/]*?)$/g.exec(url); + if (match) return match[1]; + else return ""; + } + + function mimeType(url) { + var extension = parseExtension(url).toLowerCase(); + return mimes()[extension] || ""; + } + + function isDataUrl(url) { + return url.search(/^(data:)/) !== -1; + } + + function toBlob(canvas) { + return new Promise(function (resolve) { + var binaryString = window.atob(canvas.toDataURL().split(",")[1]); + var length = binaryString.length; + var binaryArray = new Uint8Array(length); + + for (var i = 0; i < length; i++) + binaryArray[i] = binaryString.charCodeAt(i); + + resolve( + new Blob([binaryArray], { + type: "image/png", + }) + ); + }); + } + + function canvasToBlob(canvas) { + if (canvas.toBlob) + return new Promise(function (resolve) { + canvas.toBlob(resolve); + }); + + return toBlob(canvas); + } + + function resolveUrl(url, baseUrl) { + var doc = document.implementation.createHTMLDocument(); + var base = doc.createElement("base"); + doc.head.appendChild(base); + var a = doc.createElement("a"); + doc.body.appendChild(a); + base.href = baseUrl; + a.href = url; + return a.href; + } + + function uid() { + var index = 0; + + return function () { + return "u" + fourRandomChars() + index++; + + function fourRandomChars() { + /* see http://stackoverflow.com/a/6248722/2519373 */ + return ( + "0000" + ((Math.random() * Math.pow(36, 4)) << 0).toString(36) + ).slice(-4); + } + }; + } + + function makeImage(uri) { + return new Promise(function (resolve, reject) { + var image = new Image(); + image.onload = function () { + resolve(image); + }; + image.onerror = reject; + image.src = uri; + }); + } + + function getAndEncode(url) { + var TIMEOUT = 30000; + if (domtoimage.impl.options.cacheBust) { + // Cache bypass so we dont have CORS issues with cached images + // Source: https://developer.mozilla.org/en/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest#Bypassing_the_cache + url += (/\?/.test(url) ? "&" : "?") + new Date().getTime(); + } + + return new Promise(function (resolve) { + var request = new XMLHttpRequest(); + + request.onreadystatechange = done; + request.ontimeout = timeout; + request.responseType = "blob"; + request.timeout = TIMEOUT; + request.open("GET", url, true); + request.send(); + + var placeholder; + if (domtoimage.impl.options.imagePlaceholder) { + var split = domtoimage.impl.options.imagePlaceholder.split(/,/); + if (split && split[1]) { + placeholder = split[1]; + } + } + + function done() { + if (request.readyState !== 4) return; + + if (request.status !== 200) { + if (placeholder) { + resolve(placeholder); + } else { + fail( + "cannot fetch resource: " + url + ", status: " + request.status + ); + } + + return; + } + + var encoder = new FileReader(); + encoder.onloadend = function () { + var content = encoder.result.split(/,/)[1]; + resolve(content); + }; + encoder.readAsDataURL(request.response); + } + + function timeout() { + if (placeholder) { + resolve(placeholder); + } else { + fail( + "timeout of " + + TIMEOUT + + "ms occured while fetching resource: " + + url + ); + } + } + + function fail(message) { + console.error(message); + resolve(""); + } + }); + } + + function dataAsUrl(content, type) { + return "data:" + type + ";base64," + content; + } + + function escape(string) { + return string.replace(/([.*+?^${}()|\[\]\/\\])/g, "\\$1"); + } + + function delay(ms) { + return function (arg) { + return new Promise(function (resolve) { + setTimeout(function () { + resolve(arg); + }, ms); + }); + }; + } + + function asArray(arrayLike) { + var array = []; + var length = arrayLike.length; + for (var i = 0; i < length; i++) array.push(arrayLike[i]); + return array; + } + + function escapeXhtml(string) { + return string.replace(/#/g, "%23").replace(/\n/g, "%0A"); + } + + function width(node) { + var leftBorder = px(node, "border-left-width"); + var rightBorder = px(node, "border-right-width"); + return node.scrollWidth + leftBorder + rightBorder; + } + + function height(node) { + var topBorder = px(node, "border-top-width"); + var bottomBorder = px(node, "border-bottom-width"); + return node.scrollHeight + topBorder + bottomBorder; + } + + function px(node, styleProperty) { + var value = window.getComputedStyle(node).getPropertyValue(styleProperty); + return parseFloat(value.replace("px", "")); + } +} + +function newInliner() { + var URL_REGEX = /url\(['"]?([^'"]+?)['"]?\)/g; + + return { + inlineAll: inlineAll, + shouldProcess: shouldProcess, + impl: { + readUrls: readUrls, + inline: inline, + }, + }; + + function shouldProcess(string) { + return string.search(URL_REGEX) !== -1; + } + + function readUrls(string) { + var result = []; + var match; + while ((match = URL_REGEX.exec(string)) !== null) { + result.push(match[1]); + } + return result.filter(function (url) { + return !util.isDataUrl(url); + }); + } + + function inline(string, url, baseUrl, get) { + return Promise.resolve(url) + .then(function (url) { + return baseUrl ? util.resolveUrl(url, baseUrl) : url; + }) + .then(get || util.getAndEncode) + .then(function (data) { + return util.dataAsUrl(data, util.mimeType(url)); + }) + .then(function (dataUrl) { + return string.replace(urlAsRegex(url), "$1" + dataUrl + "$3"); + }); + + function urlAsRegex(url) { + return new RegExp( + "(url\\(['\"]?)(" + util.escape(url) + ")(['\"]?\\))", + "g" + ); + } + } + + function inlineAll(string, baseUrl, get) { + if (nothingToInline()) return Promise.resolve(string); + + return Promise.resolve(string) + .then(readUrls) + .then(function (urls) { + var done = Promise.resolve(string); + urls.forEach(function (url) { + done = done.then(function (string) { + return inline(string, url, baseUrl, get); + }); + }); + return done; + }); + + function nothingToInline() { + return !shouldProcess(string); + } + } +} + +function newFontFaces() { + return { + resolveAll: resolveAll, + impl: { + readAll: readAll, + }, + }; + + function resolveAll() { + return readAll(document) + .then(function (webFonts) { + return Promise.all( + webFonts.map(function (webFont) { + return webFont.resolve(); + }) + ); + }) + .then(function (cssStrings) { + return cssStrings.join("\n"); + }); + } + + function readAll() { + return Promise.resolve(util.asArray(document.styleSheets)) + .then(getCssRules) + .then(selectWebFontRules) + .then(function (rules) { + return rules.map(newWebFont); + }); + + function selectWebFontRules(cssRules) { + return cssRules + .filter(function (rule) { + return rule.type === CSSRule.FONT_FACE_RULE; + }) + .filter(function (rule) { + return inliner.shouldProcess(rule.style.getPropertyValue("src")); + }); + } + + function getCssRules(styleSheets) { + var cssRules = []; + styleSheets.forEach(function (sheet) { + try { + util + .asArray(sheet.cssRules || []) + .forEach(cssRules.push.bind(cssRules)); + } catch (e) { + console.log( + "Error while reading CSS rules from " + sheet.href, + e.toString() + ); + } + }); + return cssRules; + } + + function newWebFont(webFontRule) { + return { + resolve: function resolve() { + var baseUrl = (webFontRule.parentStyleSheet || {}).href; + return inliner.inlineAll(webFontRule.cssText, baseUrl); + }, + src: function () { + return webFontRule.style.getPropertyValue("src"); + }, + }; + } + } +} + +function newImages() { + return { + inlineAll: inlineAll, + impl: { + newImage: newImage, + }, + }; + + function newImage(element) { + return { + inline: inline, + }; + + function inline(get) { + if (util.isDataUrl(element.src)) return Promise.resolve(); + + return Promise.resolve(element.src) + .then(get || util.getAndEncode) + .then(function (data) { + return util.dataAsUrl(data, util.mimeType(element.src)); + }) + .then(function (dataUrl) { + return new Promise(function (resolve, reject) { + element.onload = resolve; + element.onerror = reject; + element.src = dataUrl; + }); + }); + } + } + + function inlineAll(node) { + if (!(node instanceof Element)) return Promise.resolve(node); + + return inlineBackground(node).then(function () { + if (node instanceof HTMLImageElement) return newImage(node).inline(); + else + return Promise.all( + util.asArray(node.childNodes).map(function (child) { + return inlineAll(child); + }) + ); + }); + + function inlineBackground(node) { + var background = node.style.getPropertyValue("background"); + + if (!background) return Promise.resolve(node); + + return inliner + .inlineAll(background) + .then(function (inlined) { + node.style.setProperty( + "background", + inlined, + node.style.getPropertyPriority("background") + ); + }) + .then(function () { + return node; + }); + } + } +} + +export default domtoimage; diff --git a/src/util/file.js b/src/util/file.js new file mode 100644 index 0000000..0ca1ce0 --- /dev/null +++ b/src/util/file.js @@ -0,0 +1,61 @@ +import { message } from "antd"; +import { remove, transaction } from "mobx"; +import { toJS } from "mobx"; +import store from "../store/global"; +import { clearHistory } from "../store/history"; +import domtoimage from "./dom-to-image"; +import { VERSION } from "./version"; + +function read(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsText(file, "utf8"); + reader.onloadend = () => { + resolve(JSON.parse(reader.result)); + }; + }); +} + +async function loadFile(file) { + const data = await read(file); + if (!(data.version <= VERSION)) { + message.error("不支持的文件格式,请使用最新版本"); + console.log(data); + return; + } + clearHistory(); + transaction(() => { + for (const key of Object.keys(store)) { + remove(store, key); + } + Object.assign(store, data); + }); +} + +function saveFile() { + const data = new Blob([JSON.stringify(toJS(store))], { + type: "application/json", + }); + const downloadLink = document.createElement("a"); + downloadLink.download = (store.title || "未标题") + ".json"; + downloadLink.href = URL.createObjectURL(data); + downloadLink.click(); + URL.revokeObjectURL(downloadLink.href); +} + +function exportFile() { + // const canvas = document.createElement("canvas"); + // const ctx = canvas.getContext("2d"); + // const dpi = window.devicePixelRatio || 1; + // canvas.style.width = store.canvasWidth; + // canvas.style.height = store.canvasHeight; + // ctx.scale(dpi, dpi); + domtoimage.toPng(document.querySelector("#temp_svg")).then((dataUrl) => { + const downloadLink = document.createElement("a"); + downloadLink.download = (store.title || "未标题") + ".png"; + downloadLink.href = dataUrl; + downloadLink.click(); + }); +} + +export { saveFile, loadFile, exportFile }; diff --git a/src/view/Canvas/index.js b/src/view/Canvas/index.js index b379d12..8740b78 100644 --- a/src/view/Canvas/index.js +++ b/src/view/Canvas/index.js @@ -1,14 +1,27 @@ import { observer } from "mobx-react-lite"; +import { useEffect, useRef } from "react"; +import state from "../../store/state"; import store from "../../store/global"; import Styles from "./index.module.css"; // ENHANCE: 增加整体缩放能力 function Canvas({ children, ...props }) { + const ref = useRef(); + useEffect(() => { + state.refs.svg = ref; + return () => { + state.refs.svg = null; + }; + }, []); + return (