first commit
BIN
src/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/fonts/RobotoMono-Bold.woff
Normal file
BIN
src/assets/fonts/RobotoMono-Regular.woff
Normal file
BIN
src/assets/fonts/lato-black-italic.woff
Normal file
BIN
src/assets/fonts/lato-black.woff
Normal file
BIN
src/assets/fonts/lato-normal-italic.woff
Normal file
BIN
src/assets/fonts/lato-normal.woff
Normal file
1
src/assets/iconBlogger.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180"><path d="M20.512 178.499c-3.359-.884-6.258-2.184-8.931-4.006-2.257-1.538-5.556-4.717-6.811-6.563-1.532-2.255-3.293-6.117-4.011-8.795-.732-2.732-.743-3.82-.757-69.395-.013-65.245.002-66.679.72-69.483C3.259 10.341 11.117 2.797 21.251.546c2.914-.647 133.08-.76 136.223-.118 8.509 1.738 15.198 6.846 19.068 14.564 3.078 6.135 2.803-.617 2.943 72.231.09 46.349.007 65.808-.288 68.232-1.386 11.345-9.211 20.143-20.471 23.019-2.88.735-3.882.746-69.275.726-63.227-.019-66.474-.052-68.939-.701z" fill="#f57d00"/><path d="M115.162 144.835c8.064-1.1 14.384-4.333 20.313-10.39 4.289-4.382 6.974-9.125 8.728-15.419.729-2.615.79-3.888.924-19.242.101-11.588.017-17.015-.285-18.385-.437-1.986-1.677-3.83-3.092-4.599-.435-.237-3.224-.538-6.198-.67-4.982-.221-5.54-.318-7.113-1.24-2.494-1.462-3.181-3.041-3.188-7.327-.013-8.189-3.421-15.792-10.155-22.654-4.797-4.889-10.149-8.198-16.257-10.052-1.462-.444-4.736-.595-15.702-.725-17.207-.203-21.026.15-26.884 2.483-10.8 4.302-18.56 13.368-21.39 24.99-.532 2.183-.635 5.682-.761 25.779-.157 25.177.016 28.874 1.59 33.864 1.299 4.122 2.611 6.648 5.313 10.234 5.146 6.83 12.86 11.763 20.572 13.156 3.67.663 48.948.829 53.585.197z" fill="#fff"/><path d="M67.575 75.717c-4.123-1.136-5.663-7.051-2.633-10.111 1.937-1.955 2.472-2.029 14.595-2.029 10.883 0 11.249.023 12.848.831 2.31 1.167 3.314 2.812 3.314 5.432 0 2.367-.943 4.025-3.046 5.357-1.129.716-1.804.76-12.467.823-6.584.039-11.83-.087-12.611-.303zM67.058 115.526c-1.769-.771-3.417-2.913-3.702-4.813-.272-1.809.638-4.296 2.032-5.558 1.757-1.59 2.528-1.643 24.134-1.66 22.227-.017 22.111-.027 24.219 1.941 2.976 2.78 2.349 7.728-1.239 9.76l-3.686.6-19.213.224c-16.883.198-21.666-.111-22.545-.494z" fill="#f57d00"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
4
src/assets/iconCouchdb.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="100%" height="100%" viewBox="0 0 500 500" version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M405.365,303.996c0,20.375 -11.207,30.563 -31.582,31.582l-248.584,0c-20.376,0 -31.583,-10.188 -31.583,-31.582c0,-20.376 11.207,-30.564 31.583,-31.583l249.602,0c20.376,1.019 30.564,11.207 30.564,31.583Zm-30.564,46.864l-249.602,0c-20.376,0 -31.583,10.188 -31.583,31.582c0,20.376 11.207,30.564 31.583,31.583l249.602,0c20.376,0 31.583,-10.188 31.583,-31.583c-1.019,-21.394 -11.207,-31.582 -31.583,-31.582Zm77.428,-172.175c-20.376,0 -31.582,10.188 -31.582,30.563l0,172.175c0,20.376 11.206,30.564 31.582,31.583c30.564,-1.019 46.864,-31.583 46.864,-93.729l0,-77.427c0,-41.771 -16.3,-62.146 -46.864,-63.165Zm-404.458,0c-30.564,1.019 -46.864,21.394 -46.864,63.165l0,77.427c0,62.146 16.3,92.71 46.864,93.729c20.376,0 31.582,-10.188 31.582,-31.583l0,-171.156c-1.019,-20.375 -11.206,-30.563 -31.582,-31.582Zm404.458,-15.282c0,-51.958 -27.507,-76.409 -77.428,-77.428l-249.602,0c-50.94,1.019 -77.428,26.489 -77.428,77.428c30.563,0 46.864,16.301 46.864,46.864c0,30.564 16.301,46.864 46.864,46.864l218.021,0c30.563,0 46.864,-16.3 46.864,-46.864c-1.019,-31.582 16.3,-45.845 45.845,-46.864Z" style="fill:#e42528;fill-rule:nonzero;"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
3
src/assets/iconCustom.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg t="1657361174041" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4922" width="32" height="32">
|
||||
<path d="M259.072 303.104q30.72 0 52.736 22.016t22.016 53.76q0 30.72-22.016 52.736t-52.736 22.016q-31.744 0-53.248-22.016t-21.504-52.736q0-31.744 21.504-53.76t53.248-22.016zM864.256 57.344q43.008 0 69.12 28.672t26.112 65.536l0 550.912q0 23.552-16.896 39.936t-40.448 16.384l-70.656 0 0-123.904 44.032 0q11.264 0 19.456-8.192t8.192-20.48q0-11.264-8.192-19.456t-19.456-8.192l-44.032 0 0-79.872 44.032 0q11.264 0 19.456-8.192t8.192-19.456-8.192-19.968-19.456-8.704l-44.032 0 0-72.704 44.032 0q11.264 0 19.456-8.192t8.192-20.48q0-11.264-8.192-19.456t-19.456-8.192l-44.032 0 0-86.016q0-57.344-26.624-80.896t-90.112-23.552l-394.24 0 0-9.216q0-23.552 16.896-39.936t40.448-16.384l486.4 0zM692.224 184.32q39.936 0 57.856 23.04t17.92 59.904l0 565.248q0 23.552-19.456 43.52t-48.128 19.968l-572.416 0q-24.576 0-44.032-20.48t-19.456-48.128l0-575.488q0-29.696 16.384-48.64t43.008-18.944l568.32 0zM703.488 291.84q0-17.408-10.752-30.208t-34.304-12.8l-488.448 0q-4.096 0-11.264 1.536t-14.336 5.12-12.288 9.728-5.12 15.36l0 274.432q8.192 9.216 23.04 22.016t34.816 23.552 44.544 18.432 53.248 7.68q43.008 0 75.264-13.824t59.904-34.816 54.272-45.056 58.88-45.568 73.728-36.352 98.816-16.896l0-142.336z" p-id="4923" fill="#1296db"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
9
src/assets/iconDropbox.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 42.4 39.5">
|
||||
<g fill="#007EE5">
|
||||
<path d="M12.5 0L0 8.1l8.7 7 12.5-7.8"/>
|
||||
<path d="M0 21.9l12.5 8.2 8.7-7.3-12.5-7.7m12.5 7.7l8.8 7.3L42.4 22l-8.6-6.9m8.6-7L30 0l-8.8 7.3 12.6 7.8"/>
|
||||
<path d="M21.3 24.4l-8.8 7.3-3.7-2.5V32l12.5 7.5L33.8 32v-2.8L30 31.7"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 338 B |
1
src/assets/iconGitea.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="32" height="32"><path d="M395.9 484.2l-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5 21.2-17.9 33.8-11.8 17.2 8.3 27.1 13 27.1 13l-.1-109.2 16.7-.1.1 117.1s57.4 24.2 83.1 40.1c3.7 2.3 10.2 6.8 12.9 14.4 2.1 6.1 2 13.1-1 19.3l-61 126.9c-6.2 12.7-21.4 18.1-33.9 12z" fill="#fff"/><path d="M622.7 149.8c-4.1-4.1-9.6-4-9.6-4s-117.2 6.6-177.9 8c-13.3.3-26.5.6-39.6.7v117.2c-5.5-2.6-11.1-5.3-16.6-7.9 0-36.4-.1-109.2-.1-109.2-29 .4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-.6-22.5-2.1-39 1.5-8.7 1.8-33.5 7.4-53.8 26.9C-4.9 212.4 6.6 276.2 8 285.8c1.7 11.7 6.9 44.2 31.7 72.5 45.8 56.1 144.4 54.8 144.4 54.8s12.1 28.9 30.6 55.5c25 33.1 50.7 58.9 75.7 62 63 0 188.9-.1 188.9-.1s12 .1 28.3-10.3c14-8.5 26.5-23.4 26.5-23.4S547 483 565 451.5c5.5-9.7 10.1-19.1 14.1-28 0 0 55.2-117.1 55.2-231.1-1.1-34.5-9.6-40.6-11.6-42.6zM125.6 353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6 321.8 60 295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5 38.5-30c13.8-3.7 31-3.1 31-3.1s7.1 59.4 15.7 94.2c7.2 29.2 24.8 77.7 24.8 77.7s-26.1-3.1-43-9.1zm300.3 107.6s-6.1 14.5-19.6 15.4c-5.8.4-10.3-1.2-10.3-1.2s-.3-.1-5.3-2.1l-112.9-55s-10.9-5.7-12.8-15.6c-2.2-8.1 2.7-18.1 2.7-18.1L322 273s4.8-9.7 12.2-13c.6-.3 2.3-1 4.5-1.5 8.1-2.1 18 2.8 18 2.8L467.4 315s12.6 5.7 15.3 16.2c1.9 7.4-.5 14-1.8 17.2-6.3 15.4-55 113.1-55 113.1z" fill="#609926"/><path d="M326.8 380.1c-8.2.1-15.4 5.8-17.3 13.8-1.9 8 2 16.3 9.1 20 7.7 4 17.5 1.8 22.7-5.4 5.1-7.1 4.3-16.9-1.8-23.1l24-49.1c1.5.1 3.7.2 6.2-.5 4.1-.9 7.1-3.6 7.1-3.6 4.2 1.8 8.6 3.8 13.2 6.1 4.8 2.4 9.3 4.9 13.4 7.3.9.5 1.8 1.1 2.8 1.9 1.6 1.3 3.4 3.1 4.7 5.5 1.9 5.5-1.9 14.9-1.9 14.9-2.3 7.6-18.4 40.6-18.4 40.6-8.1-.2-15.3 5-17.7 12.5-2.6 8.1 1.1 17.3 8.9 21.3 7.8 4 17.4 1.7 22.5-5.3 5-6.8 4.6-16.3-1.1-22.6 1.9-3.7 3.7-7.4 5.6-11.3 5-10.4 13.5-30.4 13.5-30.4.9-1.7 5.7-10.3 2.7-21.3-2.5-11.4-12.6-16.7-12.6-16.7-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3 4.7-9.7 9.4-19.3 14.1-29-4.1-2-8.1-4-12.2-6.1-4.8 9.8-9.7 19.7-14.5 29.5-6.7-.1-12.9 3.5-16.1 9.4-3.4 6.3-2.7 14.1 1.9 19.8l-24.6 50.4z" fill="#609926"/></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
5
src/assets/iconGitee.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg t="1652950823759" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2991" width="32" height="32" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs><style type="text/css"></style></defs>
|
||||
<path d="M512 1024C229.222 1024 0 794.778 0 512S229.222 0 512 0s512 229.222 512 512-229.222 512-512 512z m259.149-568.883h-290.74a25.293 25.293 0 0 0-25.292 25.293l-0.026 63.206c0 13.952 11.315 25.293 25.267 25.293h177.024c13.978 0 25.293 11.315 25.293 25.267v12.646a75.853 75.853 0 0 1-75.853 75.853h-240.23a25.293 25.293 0 0 1-25.267-25.293V417.203a75.853 75.853 0 0 1 75.827-75.853h353.946a25.293 25.293 0 0 0 25.267-25.292l0.077-63.207a25.293 25.293 0 0 0-25.268-25.293H417.152a189.62 189.62 0 0 0-189.62 189.645V771.15c0 13.977 11.316 25.293 25.294 25.293h372.94a170.65 170.65 0 0 0 170.65-170.65V480.384a25.293 25.293 0 0 0-25.293-25.267z" fill="#C71D23" p-id="2992"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
6
src/assets/iconGithub.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 58">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<path d="m1324.62 140c-16.355 0-29.616 13.219-29.616 29.527 0 13.04 8.485 24.11 20.256 28.01 1.482.27 2.02-.642 2.02-1.425 0-.7-.025-2.557-.04-5.02-8.238 1.784-9.976-3.958-9.976-3.958-1.347-3.411-3.289-4.317-3.289-4.317-2.689-1.832.204-1.796.204-1.796 2.973.21 4.536 3.043 4.536 3.043 2.642 4.511 6.931 3.208 8.62 2.454.269-1.909 1.033-3.21 1.88-3.948-6.576-.745-13.491-3.279-13.491-14.592 0-3.223 1.155-5.858 3.049-7.922-.305-.747-1.322-3.748.289-7.814 0 0 2.487-.794 8.145 3.03 2.362-.656 4.896-.982 7.415-.995 2.515.013 5.05.339 7.415.995 5.655-3.821 8.136-3.03 8.136-3.03 1.616 4.065.6 7.07.295 7.814 1.898 2.064 3.045 4.7 3.045 7.922 0 11.343-6.925 13.838-13.524 14.569 1.064.912 2.01 2.713 2.01 5.468 0 3.946-.036 7.13-.036 8.098 0 .79.533 1.709 2.036 1.421 11.758-3.913 20.238-14.971 20.238-28.01 0-16.309-13.262-29.527-29.62-29.527" transform="translate(-1295-140)" fill="#181616"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1010 B |
6
src/assets/iconGithubDark.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 58">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<path d="m1324.62 140c-16.355 0-29.616 13.219-29.616 29.527 0 13.04 8.485 24.11 20.256 28.01 1.482.27 2.02-.642 2.02-1.425 0-.7-.025-2.557-.04-5.02-8.238 1.784-9.976-3.958-9.976-3.958-1.347-3.411-3.289-4.317-3.289-4.317-2.689-1.832.204-1.796.204-1.796 2.973.21 4.536 3.043 4.536 3.043 2.642 4.511 6.931 3.208 8.62 2.454.269-1.909 1.033-3.21 1.88-3.948-6.576-.745-13.491-3.279-13.491-14.592 0-3.223 1.155-5.858 3.049-7.922-.305-.747-1.322-3.748.289-7.814 0 0 2.487-.794 8.145 3.03 2.362-.656 4.896-.982 7.415-.995 2.515.013 5.05.339 7.415.995 5.655-3.821 8.136-3.03 8.136-3.03 1.616 4.065.6 7.07.295 7.814 1.898 2.064 3.045 4.7 3.045 7.922 0 11.343-6.925 13.838-13.524 14.569 1.064.912 2.01 2.713 2.01 5.468 0 3.946-.036 7.13-.036 8.098 0 .79.533 1.709 2.036 1.421 11.758-3.913 20.238-14.971 20.238-28.01 0-16.309-13.262-29.527-29.62-29.527" transform="translate(-1295-140)" fill="#fff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1007 B |
12
src/assets/iconGitlab.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
|
||||
<svg width="100%" height="100%" viewBox="0 0 30 30" version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
|
||||
<path d="M14.581,28.019l5.369,-16.526l-10.738,0l5.369,16.526l0,0Z" style="fill:#e24329;"/>
|
||||
<path d="M14.581,28.019l-5.37,-16.526l-7.525,0l12.895,16.526l0,0Z" style="fill:#fc6d26;"/>
|
||||
<path d="M1.686,11.493l-1.632,5.022c-0.148,0.458 0.015,0.96 0.404,1.243l14.123,10.261l-12.895,-16.526l0,0Z" style="fill:#fca326;"/>
|
||||
<path d="M1.686,11.493l7.526,0l-3.235,-9.953c-0.166,-0.512 -0.89,-0.512 -1.057,0l-3.234,9.953l0,0Z" style="fill:#e24329;"/>
|
||||
<path d="M14.581,28.019l5.369,-16.526l7.526,0l-12.895,16.526l0,0Z" style="fill:#fc6d26;"/>
|
||||
<path d="M27.476,11.493l1.631,5.022c0.149,0.458 -0.014,0.96 -0.404,1.243l-14.122,10.261l12.895,-16.526l0,0Z" style="fill:#fca326;"/>
|
||||
<path d="M27.476,11.493l-7.526,0l3.234,-9.953c0.167,-0.512 0.891,-0.512 1.058,0l3.234,9.953l0,0Z" style="fill:#e24329;"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1005 B |
13
src/assets/iconGoogle.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48">
|
||||
<defs>
|
||||
<path id="a" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/>
|
||||
</defs>
|
||||
<clipPath id="b">
|
||||
<use xlink:href="#a" overflow="visible"/>
|
||||
</clipPath>
|
||||
<path clip-path="url(#b)" fill="#FBBC05" d="M0 37V11l17 13z"/>
|
||||
<path clip-path="url(#b)" fill="#EA4335" d="M0 11l17 13 7-6.1L48 14V0H0z"/>
|
||||
<path clip-path="url(#b)" fill="#34A853" d="M0 37l30-23 7.9 1L48 0v48H0z"/>
|
||||
<path clip-path="url(#b)" fill="#4285F4" d="M48 48L17 24l-4-3 35-10z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 729 B |
8
src/assets/iconGoogleDrive.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 133156 115341">
|
||||
<g>
|
||||
<polygon style="fill:#3777E3" points="22194,115341 44385,76894 133156,76894 110963,115341 "/>
|
||||
<polygon style="fill:#FFCF63" points="88772,76894 133156,76894 88772,0 44385,0 "/>
|
||||
<polygon style="fill:#11A861" points="0,76894 22194,115341 66578,38447 44385,0 "/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 363 B |
12
src/assets/iconGooglePhotos.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 511">
|
||||
<path d="M255.912,0.08c1.4,0.8 2.6,2 3.7,3.2c41.3,41.5 82.7,83 123.899,124.6c-26,25.6 -51.6,51.6 -77.399,77.3c-9.7,9.8 -19.601,19.4 -29.2,29.4c-7.2,-17.4 -14.1,-34.9 -21,-52.4c0,-18.2 0.1,-36.4 0,-54.7c-0.1,-42.4 -0.2,-84.9 0,-127.4l0,0Z" style="fill:#dc4b3e;fill-rule:nonzero;stroke:#dd4b39;stroke-width:0.09px;"/>
|
||||
<path d="M127.812,127.48l128.1,0c0.1,18.3 0,36.5 0,54.7c-7.1,17.2 -14,34.5 -20.8,51.9c-2.2,-1.2 -3.8,-3 -5.5,-4.8l-101.4,-101.4l-0.4,-0.4Z" style="fill:#ff9e0e;fill-rule:nonzero;stroke:#ef851c;stroke-width:0.09px;"/>
|
||||
<path d="M383.511,127.88l0.4,-0.3c-0.1,42.6 -0.1,85.3 0,127.9l-55.1,0c-17.2,-7.2 -34.601,-13.8 -51.9,-20.9c9.6,-10 19.5,-19.6 29.2,-29.4c25.801,-25.7 51.4,-51.7 77.4,-77.3l0,0Z" style="fill:#af195a;fill-rule:nonzero;stroke:#7e3794;stroke-width:0.09px;"/>
|
||||
<path d="M106.912,148.98c7.2,-6.9 13.9,-14.3 21.3,-21.1l101.4,101.4c1.7,1.8 3.3,3.6 5.5,4.8c-2.3,1.7 -5.2,2.3 -7.8,3.5c-14.801,6 -29.801,11.6 -44.5,18c-18.301,-0.2 -36.601,-0.1 -54.9,-0.1c-42.6,-0.1 -85.2,0.2 -127.8,-0.1c35.5,-35.6 71.2,-71 106.8,-106.4l0,0Z" style="fill:#ffc112;fill-rule:nonzero;stroke:#ffbb1b;stroke-width:0.09px;"/>
|
||||
<path d="M127.912,255.48c18.3,0 36.6,-0.1 54.9,0.1c17.3,7.1 34.6,13.8 51.899,20.8c-28.399,28.8 -57.099,57.2 -85.599,85.9c-7.2,6.8 -13.7,14.3 -21.3,20.7c0,-42.5 -0.1,-85 0.1,-127.5Z" style="fill:#17a05e;fill-rule:nonzero;stroke:#1a8763;stroke-width:0.09px;"/>
|
||||
<path d="M328.812,255.48l55.1,0c42.5,0.1 85.1,-0.1 127.6,0.1c-27.3,27.7 -55,55.1 -82.399,82.6c-15.2,15.1 -30.2,30.399 -45.4,45.3c-34,-34.4 -68.5,-68.4 -102.6,-102.8c-1.4,-1.5 -2.9,-2.8 -4.601,-3.8c2.9,-1.801 6.101,-2.7 9.2,-4c14.4,-5.8 28.799,-11.4 43.1,-17.4l0,0Z" style="fill:#4587f4;fill-rule:nonzero;stroke:#427fed;stroke-width:0.09px;"/>
|
||||
<path d="M234.712,276.38c7.3,17.399 13.9,35 21.2,52.399c-0.1,18.2 0,36.5 -0.1,54.7l0,88c-0.2,13.1 0.3,26.2 -0.2,39.2c-2.101,-1 -3.4,-2.9 -5.101,-4.5c-40.899,-41.099 -81.699,-82.199 -122.699,-123.199c7.6,-6.4 14.1,-13.9 21.3,-20.7c28.5,-28.7 57.2,-57.1 85.6,-85.9Z" style="fill:#8dc44d;fill-rule:nonzero;stroke:#65b045;stroke-width:0.09px;"/>
|
||||
<path d="M276.511,276.88c1.7,1 3.2,2.3 4.601,3.8c34.1,34.4 68.6,68.4 102.6,102.8c-42.7,-0.1 -85.3,0.1 -127.899,0c0.1,-18.2 0,-36.5 0.1,-54.7c6.699,-17.3 13.899,-34.5 20.598,-51.9l0,0Z" style="fill:#3569d6;fill-rule:nonzero;stroke:#43459d;stroke-width:0.09px;"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
25
src/assets/iconSmms.svg
Normal file
@@ -0,0 +1,25 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="32px" height="32px" viewBox="0 0 32 32" enable-background="new 0 0 32 32" xml:space="preserve"> <image id="image0" width="32" height="32" x="0" y="0"
|
||||
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAACBjSFJN
|
||||
AAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAB7FBMVEUAAAB3AP+IAP+IAP+H
|
||||
AP+IAP+IAP+GAP+HAP+AAP+GAP+IAP+IAP+HAP+KAP+OAP+IAP+IAP+IAP9xAP+IAP+IAP+KAP+F
|
||||
AP+HAP+IAP+IAP+DAP+AAP+HAP+HAP+AAP+IAP+IAP+JAP+HAP+IAP+HAP+IAP+HAP+HAP+IAP+J
|
||||
AP+GAP+IAP+IAP+IAP+HAP+HAP+HAP+HAP+IAP+IAP+IAP+HAP+IAP+HAP+IAP+IAP+HAP+HAP+H
|
||||
AP+DAP+HAP+DAP+HAP+IAP+IAP+JAv+mQv+8cP+9c/+sTv+MCf+iOP/s1//////05/+mQf+hNv/6
|
||||
9v/37/+QE//kx//Yrf+pR/+nRP/YrP+1Yf+SF//+/v+TGP/Df/+gNP/Egf+lPv+5af+aKP/8+P+j
|
||||
O//asf/27f+RFf+IAv/x4//37v/NlP/x4v+jOv+4Z//Rn/+LB/+IAf/Xqv+/eP+LBv/Wqf+7b/+3
|
||||
Zv/euP+vVf/Nlv/8+v+bKf/Dgf/Ghv/Ol/+eMf/9/P+vU/+QEf/48f/Sof+UG//ctP/48P/v3v+M
|
||||
CP/z5f/16/+aJ/+SFv/Zr//ozv/euf+cKv+WHv+tUP/r1P+mQP/Egv/Lkf+PEP/v3P/Jjf/duf/+
|
||||
/f/fu/+kPf+2Y//Cff/Bev+xWP+TGf+sor+CAAAAQ3RSTlMAD1qczOv7nFkOJp72nSUJjfqLCdTS
|
||||
IzLqL+kjCtXRCI+JJ/v5oJoR9/QNXVafmNDI8Oj++PzszJsP9SSO0iWKJ5/LDpe2AgAAAAFiS0dE
|
||||
TPdvEPMAAAAHdElNRQfmBhwAJyh2NlUnAAAByUlEQVQ4y2NggANGJmYWVjY2VhZ2Dk4GTMDFzeMM
|
||||
Bzy8fGjS/AKCzihAUEgYWZ5PxBkDiIoh5MVZnbEACUm4fiR5F1c3dw9PCFtKGiIvI4uQ9/L2AQJf
|
||||
PwhPTh6sQAEh7x/gAwaBQRC+IkheSRmhINjHJyQ0LNzHJwLCVwFZooqQj4zyiYp0do728YmBiqgx
|
||||
MKhrIBTEAk2Pc3aO9/FJgIpoajFoI3ksEaggKdk5xSc1DSakw6CLpCA9A6giM8snOwcupMegjxw2
|
||||
uWA/5OUjRAwYDJEVFBSCFEQVIUSMGIxRgre4BKyiFC5gwmCKkC0rr3AuqASpqKqGiZkymMHla3x8
|
||||
amG21MEEzRjMYcz6Bh+fRiDd1AxU4A0TZWFghjFbgOKtIEYbkNEOE7VgYIIxO4DinTAFXTBRDgZL
|
||||
WFLsBor3AOlekBV9UEErYPLlhrL7geITJk6aPAXkz6lQQWZQcoYm12k+CDAdKi9oDUoQNhDOjJkw
|
||||
6ahZLlAFQpAkB03SrbOjQNJz5s6DudAWmvTtYIm2Z/6ChYsWw0NOSozYZI8949hKI2ctGQFlVGlB
|
||||
e5SsBwQOjkiZ14rZDkv+tmRiNjdjU2Z1skDO/gDtseT0Fzic2AAAACV0RVh0ZGF0ZTpjcmVhdGUA
|
||||
MjAyMi0wNi0yOFQwMDozOTo0MCswMDowMPmC6NgAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjItMDYt
|
||||
MjhUMDA6Mzk6NDArMDA6MDCI31BkAAAAAElFTkSuQmCC" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
1
src/assets/iconStackedit.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill-rule="evenodd" stroke-linejoin="round" clip-rule="evenodd" stroke-miterlimit="1.414"><path d="M 24 6 c 0 -3.3 -2.7 -6 -6 -6 H 6 C 2.7 0 0 2.7 0 6 v 12 c 0 3.3 2.7 6 6 6 h 12 c 3.3 0 6 -2.7 6 -6 V 6 z" fill="none"/><clipPath id="prefix__a"><path d="M 24 6 c 0 -3.3 -2.7 -6 -6 -6 H 6 C 2.7 0 0 2.7 0 6 v 12 c 0 3.3 2.7 6 6 6 h 12 c 3.3 0 6 -2.7 6 -6 V 6 z"/></clipPath><g clip-path="url(#prefix__a)"><path d="M 24 0 H 0 l 12 12 L 24 0 z" fill="gold"/><path d="M 0 0 v 24 l 12 -12 L 0 0 z" fill="#a5c700"/><path d="M 0 24 h 24 L 12 12 L 0 24 z" fill="#ff8a00"/><path d="M 24 24 V 0 L 12 12 l 12 12 z" fill="#66aefd"/><path d="M 22.5 -1.5 L 12 9 l 3 3 L 25.5 1.5 l -3 -3 z" fill="url(#prefix___Linear2)"/><path d="M 25.5 22.5 L 15 12 l -3 3 l 10.5 10.5 l 3 -3 z" fill="url(#prefix___Linear3)"/><path d="M 1.5 25.5 L 12 15 l -3 -3 l -10.5 10.5 l 3 3 z" fill="url(#prefix___Linear4)"/><path d="M -1.5 1.5 L 9 12 l 3 -3 L 1.5 -1.5 l -3 3 z" fill="url(#prefix___Linear5)"/></g><path d="M 21.8 5.9 c 0 -2.2 -1.8 -4 -4 -4 H 6.3 c -2.2 0 -4 1.8 -4 4 v 11.5 c 0 2.2 1.8 4 4 4 h 11.5 c 2.2 0 4 -1.8 4 -4 V 5.9 z" fill="#ffffff"/><path d="M 4.6 6 H 6 V 4.2 h 1.4 V 6 h 1.7 V 4.2 h 1.4 V 6 h 1.4 v 1.7 h -1.4 v 1.9 h 1.4 v 1.7 h -1.4 v 1.8 H 9.1 v -1.8 H 7.4 v 1.8 H 6 v -1.8 H 4.6 V 9.6 H 6 V 7.7 H 4.6 V 6 z m 2.8 1.7 v 1.9 h 1.7 V 7.7 H 7.4 z M 10 14 v 6 h 4 v -2 h -2 v -2 h 2 v -2 H 10 z m 5 0 v 6 h 2 v -3 l 1 3 h 2 v -6 h -2 v 3 l -1 -3 h -2 z M 7 18 l 0 2 l 2 0 l 0 -2 l -2 0 Z" fill="#737373"/><defs><linearGradient id="prefix___Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-2.99995 -3 3 -2.99995 23.997 3.003)"><stop offset="0" stop-color="#66aefd"/><stop offset="1" stop-color="gold"/></linearGradient><linearGradient id="prefix___Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(3 -2.99995 2.99995 3 20.999 24.003)"><stop offset="0" stop-color="#ff8a00"/><stop offset="1" stop-color="#66aefd"/></linearGradient><linearGradient id="prefix___Linear4" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2.99995 3 -3 2.99995 -.003 21.001)"><stop offset="0" stop-color="#a5c700"/><stop offset="1" stop-color="#ff8a00"/></linearGradient><linearGradient id="prefix___Linear5" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3 2.99995 -2.99995 -3 2.997 .003)"><stop offset="0" stop-color="gold"/><stop offset="1" stop-color="#a5c700"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
10
src/assets/iconWordpress.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<g id="_x32__stroke">
|
||||
<g id="Wordpress_1_">
|
||||
<rect clip-rule="evenodd" fill="none" fill-rule="evenodd" height="128" width="128"/>
|
||||
<path clip-rule="evenodd" d="M65.123,69.595l-19.205,55.797 c5.736,1.688,11.8,2.608,18.081,2.608c7.452,0,14.6-1.288,21.253-3.628c-0.168-0.276-0.328-0.564-0.456-0.88L65.123,69.595z M120.16,33.294c0.276,2.04,0.432,4.224,0.432,6.58c0,6.492-1.216,13.792-4.868,22.924l-19.549,56.517 C115.204,108.223,128,87.606,128,63.998C128,52.87,125.156,42.41,120.16,33.294z M107.204,60.769 c0-7.912-2.844-13.388-5.276-17.648c-3.244-5.276-6.288-9.74-6.288-15.012c0-5.884,4.46-11.36,10.748-11.36 c0.284,0,0.552,0.036,0.828,0.052C95.832,6.368,80.659,0,63.999,0C41.638,0,21.969,11.472,10.525,28.844 c1.504,0.048,2.92,0.076,4.12,0.076c6.692,0,17.057-0.812,17.057-0.812c3.448-0.204,3.856,4.868,0.408,5.272 c0,0-3.468,0.408-7.324,0.612l23.305,69.321l14.008-42.005L52.13,33.992c-3.448-0.204-6.716-0.612-6.716-0.612 c-3.448-0.204-3.044-5.476,0.408-5.272c0,0,10.568,0.812,16.857,0.812c6.692,0,17.057-0.812,17.057-0.812 c3.452-0.204,3.856,4.868,0.408,5.272c0,0-3.472,0.408-7.324,0.612l23.129,68.793l6.388-21.328 C105.096,72.601,107.204,66.245,107.204,60.769z M0,63.997c0,25.332,14.72,47.225,36.069,57.597L5.54,37.952 C1.992,45.909,0,54.717,0,63.997z" fill="#00759D" fill-rule="evenodd" id="Wordpress"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
10
src/assets/iconZendesk.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 152 116">
|
||||
<g>
|
||||
<path d="M70.125,30.375l0,84.675l-70.125,0l70.125,-84.675Z" style="fill:#03363d;fill-rule:nonzero;"/>
|
||||
<path d="M70.125,0c0,19.35 -15.675,35.025 -35.025,35.025c-19.35,0 -35.1,-15.675 -35.1,-35.025l70.125,0Z" style="fill:#03363d;fill-rule:nonzero;"/>
|
||||
<path d="M81.675,115.05c0,-19.35 15.675,-35.025 35.025,-35.025c19.35,0 35.025,15.675 35.025,35.025l-70.05,0Z" style="fill:#03363d;fill-rule:nonzero;"/>
|
||||
<path d="M81.675,84.675l0,-84.675l70.125,0l-70.125,84.675Z" style="fill:#03363d;fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 605 B |
1
src/assets/logo.svg
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
109
src/components/App.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="app" :class="classes" @keydown.esc="close">
|
||||
<splash-screen v-if="!ready"></splash-screen>
|
||||
<layout v-else></layout>
|
||||
<modal></modal>
|
||||
<notification></notification>
|
||||
<context-menu></context-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import '../styles';
|
||||
import '../styles/markdownHighlighting.scss';
|
||||
import '../styles/app.scss';
|
||||
import Layout from './Layout';
|
||||
import Modal from './Modal';
|
||||
import Notification from './Notification';
|
||||
import ContextMenu from './ContextMenu';
|
||||
import SplashScreen from './SplashScreen';
|
||||
import syncSvc from '../services/syncSvc';
|
||||
import networkSvc from '../services/networkSvc';
|
||||
import tempFileSvc from '../services/tempFileSvc';
|
||||
import store from '../store';
|
||||
import './common/vueGlobals';
|
||||
import utils from '../services/utils';
|
||||
import providerRegistry from '../services/providers/common/providerRegistry';
|
||||
|
||||
const themeClasses = {
|
||||
light: ['app--light'],
|
||||
dark: ['app--dark'],
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Layout,
|
||||
Modal,
|
||||
Notification,
|
||||
ContextMenu,
|
||||
SplashScreen,
|
||||
},
|
||||
data: () => ({
|
||||
ready: false,
|
||||
}),
|
||||
computed: {
|
||||
classes() {
|
||||
const result = themeClasses[store.getters['data/computedSettings'].colorTheme];
|
||||
return Array.isArray(result) ? result : themeClasses.light;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
close() {
|
||||
tempFileSvc.close();
|
||||
},
|
||||
// 通过路径查看文件 支持相对路径
|
||||
viewFileByPath(path) {
|
||||
// 如果是md结尾
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
const currDirNode = store.getters['explorer/selectedNodeFolder'];
|
||||
if (path.slice(-3) === '.md') {
|
||||
const rootNode = store.getters['explorer/rootNode'];
|
||||
const node = utils.findNodeByPath(rootNode, currDirNode, path);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
store.commit('explorer/setSelectedId', node.item.id);
|
||||
// Prevent from freezing the UI while loading the file
|
||||
setTimeout(() => {
|
||||
store.commit('file/setCurrentId', node.item.id);
|
||||
}, 10);
|
||||
} else {
|
||||
const workspace = store.getters['workspace/currentWorkspace'];
|
||||
const provider = providerRegistry.providersById[workspace.providerId];
|
||||
if (provider == null) {
|
||||
return;
|
||||
}
|
||||
const absolutePath = utils.getAbsoluteFilePath(currDirNode, path);
|
||||
const url = provider.getFilePathUrl(absolutePath);
|
||||
if (url) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
window.viewFileByPath = this.viewFileByPath;
|
||||
try {
|
||||
await syncSvc.init();
|
||||
await networkSvc.init();
|
||||
// store 编辑主题
|
||||
const editTheme = localStorage.getItem('theme/currEditTheme');
|
||||
store.dispatch('theme/setEditTheme', editTheme || 'default');
|
||||
// store 预览主题
|
||||
const previewTheme = localStorage.getItem('theme/currPreviewTheme');
|
||||
store.dispatch('theme/setPreviewTheme', previewTheme || 'default');
|
||||
this.ready = true;
|
||||
tempFileSvc.setReady();
|
||||
} catch (err) {
|
||||
if (err && err.message === 'RELOAD') {
|
||||
window.location.reload();
|
||||
} else if (err && err.message !== 'RELOAD') {
|
||||
console.error(err); // eslint-disable-line no-console
|
||||
store.dispatch('notification/error', err);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
109
src/components/ButtonBar.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="button-bar">
|
||||
<div class="button-bar__inner button-bar__inner--top">
|
||||
<button class="button-bar__button button-bar__button--navigation-bar-toggler button" :class="{ 'button-bar__button--on': layoutSettings.showNavigationBar }" v-if="!light" @click="toggleNavigationBar()" v-title="'切换导航栏'">
|
||||
<icon-navigation-bar></icon-navigation-bar>
|
||||
</button>
|
||||
<button class="button-bar__button button-bar__button--side-preview-toggler button" :class="{ 'button-bar__button--on': layoutSettings.showSidePreview }" tour-step-anchor="editor" @click="toggleSidePreview()" v-title="'切换侧边预览'">
|
||||
<icon-side-preview></icon-side-preview>
|
||||
</button>
|
||||
<button class="button-bar__button button-bar__button--editor-toggler button" @click="toggleEditor(false)" v-title="'阅读模式'">
|
||||
<icon-eye></icon-eye>
|
||||
</button>
|
||||
</div>
|
||||
<div class="button-bar__inner button-bar__inner--bottom">
|
||||
<button class="button-bar__button button-bar__button--focus-mode-toggler button" :class="{ 'button-bar__button--on': layoutSettings.focusMode }" @click="toggleFocusMode()" v-title="'切换对焦模式'">
|
||||
<icon-target></icon-target>
|
||||
</button>
|
||||
<button class="button-bar__button button-bar__button--scroll-sync-toggler button" :class="{ 'button-bar__button--on': layoutSettings.scrollSync }" @click="toggleScrollSync()" v-title="'切换滚动同步'">
|
||||
<icon-scroll-sync></icon-scroll-sync>
|
||||
</button>
|
||||
<button class="button-bar__button button-bar__button--status-bar-toggler button" :class="{ 'button-bar__button--on': layoutSettings.showStatusBar }" @click="toggleStatusBar()" v-title="'切换状态栏'">
|
||||
<icon-status-bar></icon-status-bar>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapGetters, mapActions } from 'vuex';
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
...mapState([
|
||||
'light',
|
||||
]),
|
||||
...mapGetters('data', [
|
||||
'layoutSettings',
|
||||
]),
|
||||
},
|
||||
methods: mapActions('data', [
|
||||
'toggleNavigationBar',
|
||||
'toggleEditor',
|
||||
'toggleSidePreview',
|
||||
'toggleStatusBar',
|
||||
'toggleFocusMode',
|
||||
'toggleScrollSync',
|
||||
]),
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/variables.scss';
|
||||
|
||||
.button-bar {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.button-bar__inner {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.button-bar__inner--bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.button-bar__button {
|
||||
color: rgba(0, 0, 0, 0.2);
|
||||
display: block;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 2px;
|
||||
margin: 3px 0;
|
||||
|
||||
.app--dark & {
|
||||
color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: rgba(0, 0, 0, 0.2);
|
||||
|
||||
.app--dark & {
|
||||
color: rgba(255, 255, 255, 0.15);
|
||||
background-color: $navbar-hover-background;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.button-bar__button--on {
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
|
||||
.app--dark & {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
|
||||
.app--dark & {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
56
src/components/CodeEditor.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<pre class="code-editor textfield prism" :disabled="disabled"></pre>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Prism from 'prismjs';
|
||||
import cledit from '../services/editor/cledit';
|
||||
|
||||
export default {
|
||||
props: ['value', 'lang', 'disabled', 'scrollClass'],
|
||||
mounted() {
|
||||
const preElt = this.$el;
|
||||
let scrollElt = preElt;
|
||||
const scrollCls = this.scrollClass || 'modal';
|
||||
while (scrollElt && !scrollElt.classList.contains(scrollCls)) {
|
||||
scrollElt = scrollElt.parentNode;
|
||||
}
|
||||
if (scrollElt) {
|
||||
const clEditor = cledit(preElt, scrollElt);
|
||||
clEditor.on('contentChanged', value => this.$emit('changed', value));
|
||||
clEditor.init({
|
||||
content: this.value,
|
||||
sectionHighlighter: section => Prism.highlight(section.text, Prism.languages[this.lang]),
|
||||
});
|
||||
clEditor.toggleEditable(!this.disabled);
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/variables.scss';
|
||||
|
||||
.code-editor {
|
||||
margin: 0;
|
||||
font-family: $font-family-monospace;
|
||||
font-size: $font-size-monospace;
|
||||
font-variant-ligatures: no-common-ligatures;
|
||||
word-break: break-word;
|
||||
word-wrap: normal;
|
||||
height: auto;
|
||||
caret-color: #000;
|
||||
min-height: 160px;
|
||||
overflow: auto;
|
||||
padding: 0.2em 0.4em;
|
||||
|
||||
.app--dark & {
|
||||
caret-color: $editor-color-dark-low;
|
||||
}
|
||||
|
||||
* {
|
||||
line-height: $line-height-base;
|
||||
font-size: inherit !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
79
src/components/ContextMenu.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="context-menu" v-if="items.length" @click="close()" @contextmenu.prevent="close()">
|
||||
<div class="context-menu__inner flex flex--column" :style="{ left: coordinates.left + 'px', top: coordinates.top + 'px' }" @click.stop>
|
||||
<div v-for="(item, idx) in items" :key="idx">
|
||||
<div class="context-menu__separator" v-if="item.type === 'separator'"></div>
|
||||
<div class="context-menu__item context-menu__item--disabled" v-else-if="item.disabled">{{item.name}}</div>
|
||||
<a class="context-menu__item" href="javascript:void(0)" v-else @click="close(item)">{{item.name}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from 'vuex';
|
||||
import store from '../store';
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
...mapState('contextMenu', [
|
||||
'coordinates',
|
||||
'items',
|
||||
'resolve',
|
||||
]),
|
||||
},
|
||||
methods: {
|
||||
close(item = null) {
|
||||
this.resolve(item);
|
||||
store.dispatch('contextMenu/close');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.context-menu {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
$padding: 5px;
|
||||
|
||||
.context-menu__inner {
|
||||
position: absolute;
|
||||
background-color: #ebebeb;
|
||||
border-radius: $padding;
|
||||
padding: $padding 0;
|
||||
box-shadow: 0 6px 10px rgba(0, 0, 0, 0.16), 0 3px 10px 1px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.context-menu__item {
|
||||
display: block;
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
padding: 0 25px;
|
||||
}
|
||||
|
||||
a.context-menu__item {
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
background-color: #338dfc;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu__item--disabled {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.context-menu__separator {
|
||||
border-top: 2px solid #dcdcdd;
|
||||
margin: $padding 0;
|
||||
}
|
||||
</style>
|
||||
171
src/components/Editor.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div class="editor" ondrop="return false;">
|
||||
<pre class="editor__inner markdown-highlighting" :style="{padding: styles.editorPadding}" :class="{monospaced: computedSettings.editor.monospacedFontOnly}"></pre>
|
||||
<div class="gutter" :style="{left: styles.editorGutterLeft + 'px'}">
|
||||
<comment-list v-if="styles.editorGutterWidth"></comment-list>
|
||||
<editor-new-discussion-button v-if="!isCurrentTemp"></editor-new-discussion-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import CommentList from './gutters/CommentList';
|
||||
import EditorNewDiscussionButton from './gutters/EditorNewDiscussionButton';
|
||||
import store from '../store';
|
||||
import editorSvc from '../services/editorSvc';
|
||||
import imageSvc from '../services/imageSvc';
|
||||
import utils from '../services/utils';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CommentList,
|
||||
EditorNewDiscussionButton,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('file', [
|
||||
'isCurrentTemp',
|
||||
]),
|
||||
...mapGetters('layout', [
|
||||
'styles',
|
||||
]),
|
||||
...mapGetters('data', [
|
||||
'computedSettings',
|
||||
]),
|
||||
},
|
||||
methods: {
|
||||
async processUpload(items) {
|
||||
let file = null;
|
||||
if (!items || items.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
if (items[i].type.indexOf('image') !== -1) {
|
||||
file = items[i].getAsFile();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const imgId = utils.uid();
|
||||
store.dispatch('img/setCurrImgId', imgId);
|
||||
editorSvc.pagedownEditor.uiManager.doClick('imageUploading');
|
||||
try {
|
||||
const { url, error } = await imageSvc.updateImg(file);
|
||||
// 存在错误
|
||||
if (error) {
|
||||
editorSvc.clEditor.replaceAll(`[图片上传中...(image-${imgId})]`, `[图片上传失败...(image-${imgId})]`);
|
||||
store.dispatch('notification/error', error);
|
||||
return;
|
||||
}
|
||||
editorSvc.clEditor.replaceAll(`[图片上传中...(image-${imgId})]`, ``);
|
||||
} catch (err) {
|
||||
console.error(err); // eslint-disable-line no-console
|
||||
editorSvc.clEditor.replaceAll(`[图片上传中...(image-${imgId})]`, `[图片上传失败...(image-${imgId})]`);
|
||||
store.dispatch('notification/error', err);
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// 当前选择的图片存储图床
|
||||
const currImgStorageStr = localStorage.getItem('img/checkedStorage');
|
||||
if (currImgStorageStr) {
|
||||
store.commit('img/changeCheckedStorage', JSON.parse(currImgStorageStr));
|
||||
}
|
||||
// 当前本地图片路径配置
|
||||
const workspaceImgPath = localStorage.getItem('img/workspaceImgPath');
|
||||
if (workspaceImgPath) {
|
||||
store.commit('img/setWorkspaceImgPath', JSON.parse(workspaceImgPath));
|
||||
}
|
||||
const editorElt = this.$el.querySelector('.editor__inner');
|
||||
const onDiscussionEvt = cb => (evt) => {
|
||||
let elt = evt.target;
|
||||
while (elt && elt !== editorElt) {
|
||||
if (elt.discussionId) {
|
||||
cb(elt.discussionId);
|
||||
return;
|
||||
}
|
||||
elt = elt.parentNode;
|
||||
}
|
||||
};
|
||||
|
||||
const classToggler = toggle => (discussionId) => {
|
||||
editorElt.getElementsByClassName(`discussion-editor-highlighting--${discussionId}`)
|
||||
.cl_each(elt => elt.classList.toggle('discussion-editor-highlighting--hover', toggle));
|
||||
document.getElementsByClassName(`comment--discussion-${discussionId}`)
|
||||
.cl_each(elt => elt.classList.toggle('comment--hover', toggle));
|
||||
};
|
||||
|
||||
editorElt.addEventListener('mouseover', onDiscussionEvt(classToggler(true)));
|
||||
editorElt.addEventListener('mouseout', onDiscussionEvt(classToggler(false)));
|
||||
editorElt.addEventListener('click', onDiscussionEvt((discussionId) => {
|
||||
store.commit('discussion/setCurrentDiscussionId', discussionId);
|
||||
}));
|
||||
|
||||
editorElt.addEventListener('drop', (event) => {
|
||||
const transItems = event.dataTransfer.items;
|
||||
this.processUpload(transItems);
|
||||
});
|
||||
editorElt.addEventListener('paste', (event) => {
|
||||
const pasteItems = (event.clipboardData || window.clipboardData).items;
|
||||
this.processUpload(pasteItems);
|
||||
});
|
||||
|
||||
this.$watch(
|
||||
() => store.state.discussion.currentDiscussionId,
|
||||
(discussionId, oldDiscussionId) => {
|
||||
if (oldDiscussionId) {
|
||||
editorElt.querySelectorAll(`.discussion-editor-highlighting--${oldDiscussionId}`)
|
||||
.cl_each(elt => elt.classList.remove('discussion-editor-highlighting--selected'));
|
||||
}
|
||||
if (discussionId) {
|
||||
editorElt.querySelectorAll(`.discussion-editor-highlighting--${discussionId}`)
|
||||
.cl_each(elt => elt.classList.add('discussion-editor-highlighting--selected'));
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/variables.scss';
|
||||
|
||||
.editor {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.editor__inner {
|
||||
margin: 0;
|
||||
font-family: $font-family-main;
|
||||
font-variant-ligatures: no-common-ligatures;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
|
||||
* {
|
||||
line-height: $line-height-base;
|
||||
}
|
||||
|
||||
.cledit-section {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.monospaced {
|
||||
font-family: $font-family-monospace !important;
|
||||
font-size: $font-size-monospace !important;
|
||||
|
||||
* {
|
||||
font-size: inherit !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
197
src/components/EditorInPageButtons.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="editor-in-page-buttons">
|
||||
<ul>
|
||||
<li :title="`查找 ${mod}+F`">
|
||||
<a @click="showFind"><icon-search></icon-search></a>
|
||||
</li>
|
||||
<li :title="`替换 ${mod}+Alt+F`">
|
||||
<a @click="showFindReplace"><icon-find-replace></icon-find-replace></a>
|
||||
</li>
|
||||
<li title="切换编辑主题">
|
||||
<dropdown-menu :selected="selectedTheme" :options="allThemes" :closeOnItemClick="false" @change="changeTheme">
|
||||
<icon-select-theme></icon-select-theme>
|
||||
</dropdown-menu>
|
||||
</li>
|
||||
<li class="after">
|
||||
<icon-ellipsis></icon-ellipsis>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters, mapActions } from 'vuex';
|
||||
import store from '../store';
|
||||
import editorSvc from '../services/editorSvc';
|
||||
import DropdownMenu from './common/DropdownMenu';
|
||||
|
||||
const mod = /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? 'Meta' : 'Ctrl';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DropdownMenu,
|
||||
},
|
||||
data: () => ({
|
||||
mod,
|
||||
allThemes: [{
|
||||
name: '默认主题',
|
||||
value: 'default',
|
||||
}, {
|
||||
name: '天蓝黑',
|
||||
value: 'azure',
|
||||
}, {
|
||||
name: '冰山黑',
|
||||
value: 'iceberg_contrast',
|
||||
}, {
|
||||
name: '黎明白',
|
||||
value: 'dawn',
|
||||
}, {
|
||||
name: '孔雀黑',
|
||||
value: 'peacock',
|
||||
}, {
|
||||
name: '薄荷黑',
|
||||
value: 'mintchoc',
|
||||
}, {
|
||||
name: '薄荷绿',
|
||||
value: 'spearmint',
|
||||
}, {
|
||||
name: '暗蓝黑',
|
||||
value: 'slate',
|
||||
}, {
|
||||
name: '文墨黑',
|
||||
value: 'carbonight',
|
||||
}, {
|
||||
name: '日光白',
|
||||
value: 'solarized_light',
|
||||
}, {
|
||||
name: '咖啡黑',
|
||||
value: 'espresso_libre',
|
||||
}, {
|
||||
name: '薰衣草黑',
|
||||
value: 'lavender',
|
||||
}, {
|
||||
name: '耀斑黑',
|
||||
value: 'solarflare',
|
||||
}, {
|
||||
name: 'Clouds白',
|
||||
value: 'clouds',
|
||||
}, {
|
||||
name: 'Clouds黑',
|
||||
value: 'clouds_midnight',
|
||||
}, {
|
||||
name: 'GitHub白',
|
||||
value: 'github',
|
||||
}, {
|
||||
name: '自定义',
|
||||
value: 'custom',
|
||||
}],
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters('theme', [
|
||||
'currEditTheme',
|
||||
'customEditThemeStyle',
|
||||
]),
|
||||
selectedTheme() {
|
||||
return {
|
||||
value: this.currEditTheme || 'default',
|
||||
};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions('data', [
|
||||
'toggleSideBar',
|
||||
]),
|
||||
showFind() {
|
||||
store.dispatch('findReplace/open', {
|
||||
type: 'find',
|
||||
findText: editorSvc.clEditor.selectionMgr.hasFocus() &&
|
||||
editorSvc.clEditor.selectionMgr.getSelectedText(),
|
||||
});
|
||||
},
|
||||
showFindReplace() {
|
||||
store.dispatch('findReplace/open', {
|
||||
type: 'replace',
|
||||
findText: editorSvc.clEditor.selectionMgr.hasFocus() &&
|
||||
editorSvc.clEditor.selectionMgr.getSelectedText(),
|
||||
});
|
||||
},
|
||||
async changeTheme(item) {
|
||||
await store.dispatch('theme/setEditTheme', item.value);
|
||||
// 如果自定义主题没内容 则弹出编辑区域
|
||||
if (item.value === 'custom' && !this.customEditThemeStyle) {
|
||||
this.toggleSideBar(true);
|
||||
store.dispatch('data/setSideBarPanel', 'editTheme');
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/variables.scss';
|
||||
|
||||
.editor-in-page-buttons {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -108px;
|
||||
height: 34px;
|
||||
padding: 5px;
|
||||
background-color: rgba(84, 96, 114, 0.4);
|
||||
border-radius: $border-radius-base;
|
||||
transition: 0.5s;
|
||||
display: flex;
|
||||
|
||||
.dropdown-menu {
|
||||
display: none;
|
||||
|
||||
.dropdown-menu-items {
|
||||
right: unset;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
left: 0;
|
||||
transition: 0.5s;
|
||||
background-color: #546072;
|
||||
|
||||
.dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
padding: 0;
|
||||
margin-left: 10px;
|
||||
line-height: 20px;
|
||||
|
||||
li {
|
||||
width: 16px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: #dea731;
|
||||
opacity: 0.7;
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.after {
|
||||
margin-left: 0;
|
||||
margin-right: -6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
225
src/components/Explorer.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div class="explorer flex flex--column">
|
||||
<div class="side-title flex flex--row flex--space-between">
|
||||
<div class="flex flex--row" v-if="!showSearch">
|
||||
<button class="side-title__button side-title__button--new-file button" @click="newItem()" v-title="'创建文件'">
|
||||
<icon-file-plus></icon-file-plus>
|
||||
</button>
|
||||
<button class="side-title__button side-title__button--new-folder button" @click="newItem(true)" v-title="'创建文件夹'">
|
||||
<icon-folder-plus></icon-folder-plus>
|
||||
</button>
|
||||
<button class="side-title__button side-title__button--delete button" @click="deleteItem()" v-title="'删除'">
|
||||
<icon-delete></icon-delete>
|
||||
</button>
|
||||
<button class="side-title__button side-title__button--rename button" @click="editItem()" v-title="'重命名'">
|
||||
<icon-pen></icon-pen>
|
||||
</button>
|
||||
<button class="side-title__button side-title__button--search button" @click="toSearch()" v-title="'搜索文件'">
|
||||
<icon-file-search></icon-file-search>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex--row" v-else>
|
||||
<button class="side-title__button button" @click="back()" v-title="'返回资源管理器'">
|
||||
<icon-dots-horizontal></icon-dots-horizontal>
|
||||
</button>
|
||||
<div class="side-title__title">
|
||||
搜索文件
|
||||
</div>
|
||||
</div>
|
||||
<button class="side-title__button side-title__button--close button" @click="toggleExplorer(false)" v-title="'关闭资源管理器'">
|
||||
<icon-close></icon-close>
|
||||
</button>
|
||||
</div>
|
||||
<div class="explorer__tree" :class="{'explorer__tree--new-item': !newChildNode.isNil}" v-if="!light" v-show="!showSearch" tabindex="0" @keydown.delete="deleteItem()">
|
||||
<explorer-node :node="rootNode" :depth="0"></explorer-node>
|
||||
</div>
|
||||
<div class="explorer__search" tabindex="0" v-if="!light && showSearch">
|
||||
<input type="text" v-model="searchText" class="text-input" placeholder="请输入关键字回车" @keyup.enter="search" />
|
||||
<div class="explorer__search-list">
|
||||
<div class="search-tips" v-if="searching">正在查询中...</div>
|
||||
<a class="menu-entry button flex flex--row flex--align-center" :class="{'search-node--selected': currentFileId === fileItem.id}"
|
||||
v-for="fileItem in searchItems" :key="fileItem.id" @click="clickSearch(fileItem)" href="javascript:void(0)">
|
||||
{{ fileItem.name }}
|
||||
</a>
|
||||
<div class="search-tips">最多返回匹配的50个文档</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapGetters, mapActions } from 'vuex';
|
||||
import ExplorerNode from './ExplorerNode';
|
||||
import explorerSvc from '../services/explorerSvc';
|
||||
import store from '../store';
|
||||
import MenuEntry from './menus/common/MenuEntry';
|
||||
import localDbSvc from '../services/localDbSvc';
|
||||
import badgeSvc from '../services/badgeSvc';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ExplorerNode,
|
||||
MenuEntry,
|
||||
},
|
||||
data: () => ({
|
||||
currentFileId: '',
|
||||
showSearch: false,
|
||||
searching: false,
|
||||
searchText: '',
|
||||
searchItems: [],
|
||||
}),
|
||||
computed: {
|
||||
...mapState([
|
||||
'light',
|
||||
]),
|
||||
...mapState('explorer', [
|
||||
'newChildNode',
|
||||
]),
|
||||
...mapGetters('explorer', [
|
||||
'rootNode',
|
||||
'selectedNode',
|
||||
]),
|
||||
workspaceId: () => store.getters['workspace/currentWorkspace'].id,
|
||||
},
|
||||
methods: {
|
||||
...mapActions('data', [
|
||||
'toggleExplorer',
|
||||
]),
|
||||
newItem: isFolder => explorerSvc.newItem(isFolder),
|
||||
deleteItem: () => explorerSvc.deleteItem(),
|
||||
editItem() {
|
||||
const node = this.selectedNode;
|
||||
if (!node.isTrash && !node.isTemp) {
|
||||
store.commit('explorer/setEditingId', node.item.id);
|
||||
}
|
||||
},
|
||||
back() {
|
||||
this.showSearch = false;
|
||||
},
|
||||
toSearch() {
|
||||
this.showSearch = true;
|
||||
},
|
||||
search() {
|
||||
this.searchItems = [];
|
||||
if (!this.searchText) {
|
||||
return;
|
||||
}
|
||||
this.searching = true;
|
||||
const allFileById = {};
|
||||
const filterIds = [];
|
||||
localDbSvc.getWorkspaceItems(this.workspaceId, (item) => {
|
||||
if (item.type !== 'file' && item.type !== 'content') {
|
||||
return;
|
||||
}
|
||||
if (item.type === 'file') {
|
||||
allFileById[item.id] = item;
|
||||
}
|
||||
if (filterIds.length >= 50) {
|
||||
return;
|
||||
}
|
||||
const fileId = item.id.split('/')[0];
|
||||
// 包含了直接跳过
|
||||
if (filterIds.indexOf(fileId) > -1) {
|
||||
return;
|
||||
}
|
||||
if (item.name && item.name.indexOf(this.searchText) > -1) {
|
||||
filterIds.push(fileId);
|
||||
}
|
||||
if (item.text && item.text.indexOf(this.searchText) > -1) {
|
||||
filterIds.push(fileId);
|
||||
}
|
||||
}, () => {
|
||||
filterIds.forEach((it) => {
|
||||
const file = allFileById[it];
|
||||
if (file) {
|
||||
this.searchItems.push(file);
|
||||
}
|
||||
});
|
||||
this.searching = false;
|
||||
badgeSvc.addBadge('searchFile');
|
||||
});
|
||||
},
|
||||
clickSearch(item) {
|
||||
const node = store.getters['explorer/nodeMap'][item.id];
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
store.commit('explorer/setSelectedId', item.id);
|
||||
// Prevent from freezing the UI while loading the file
|
||||
setTimeout(() => {
|
||||
store.commit('file/setCurrentId', item.id);
|
||||
}, 10);
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.$watch(
|
||||
() => store.getters['file/current'].id,
|
||||
(currentFileId) => {
|
||||
this.currentFileId = currentFileId;
|
||||
store.commit('explorer/setSelectedId', currentFileId);
|
||||
store.dispatch('explorer/openNode', currentFileId);
|
||||
}, {
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/variables.scss';
|
||||
|
||||
.explorer,
|
||||
.explorer__tree {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.explorer__tree {
|
||||
overflow: auto;
|
||||
|
||||
/* fake element */
|
||||
& > .explorer-node > .explorer-node__children > .explorer-node:last-child > .explorer-node__item {
|
||||
height: 20px;
|
||||
cursor: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.explorer__search {
|
||||
overflow: auto;
|
||||
|
||||
.explorer__search-list {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.menu-entry {
|
||||
font-size: 14px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.menu-entry__icon {
|
||||
width: 0;
|
||||
margin-left: 0;
|
||||
border-bottom: 1px solid $hr-color;
|
||||
}
|
||||
|
||||
.search-tips {
|
||||
font-size: 10px;
|
||||
background-color: rgba(255, 173, 51, 0.14902);
|
||||
padding: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search-node--selected {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
|
||||
.app--dark & {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: #39f;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
292
src/components/ExplorerNode.vue
Normal file
@@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<div class="explorer-node" :class="{'explorer-node--selected': isSelected, 'explorer-node--folder': node.isFolder, 'explorer-node--open': isOpen, 'explorer-node--trash': node.isTrash, 'explorer-node--temp': node.isTemp, 'explorer-node--drag-target': isDragTargetFolder}" @dragover.prevent @dragenter.stop="node.noDrop || setDragTarget(node)" @dragleave.stop="isDragTarget && setDragTarget()" @drop.prevent.stop="onDrop" @contextmenu="onContextMenu">
|
||||
<div class="explorer-node__item-editor" v-if="isEditing" :style="{paddingLeft: leftPadding}" draggable="true" @dragstart.stop.prevent>
|
||||
<input type="text" class="text-input" v-focus @blur="submitEdit()" @keydown.stop @keydown.enter="submitEdit()" @keydown.esc.stop="submitEdit(true)" v-model="editingNodeName">
|
||||
</div>
|
||||
<div class="explorer-node__item" v-else :style="{paddingLeft: leftPadding}" @click="select()" draggable="true" @dragstart.stop="setDragSourceId" @dragend.stop="setDragTarget()">
|
||||
{{node.item.name}}
|
||||
<icon-provider class="explorer-node__location" v-for="location in node.locations" :key="location.id" :provider-id="location.providerId"></icon-provider>
|
||||
</div>
|
||||
<div class="explorer-node__children" v-if="node.isFolder && isOpen">
|
||||
<explorer-node v-for="node in node.folders" :key="node.item.id" :node="node" :depth="depth + 1"></explorer-node>
|
||||
<div v-if="newChild" class="explorer-node__new-child" :class="{'explorer-node__new-child--folder': newChild.isFolder}" :style="{paddingLeft: childLeftPadding}">
|
||||
<input type="text" class="text-input" v-focus @blur="submitNewChild()" @keydown.stop @keydown.enter="submitNewChild()" @keydown.esc.stop="submitNewChild(true)" v-model.trim="newChildName">
|
||||
</div>
|
||||
<explorer-node v-for="node in node.files" :key="node.item.id" :node="node" :depth="depth + 1"></explorer-node>
|
||||
</div>
|
||||
<button ref="copyId" v-clipboard="copyPath()" @click="info('路径已复制到剪切板!')" style="display: none;"></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapMutations, mapActions } from 'vuex';
|
||||
import workspaceSvc from '../services/workspaceSvc';
|
||||
import explorerSvc from '../services/explorerSvc';
|
||||
import store from '../store';
|
||||
import badgeSvc from '../services/badgeSvc';
|
||||
import utils from '../services/utils';
|
||||
|
||||
export default {
|
||||
name: 'explorer-node', // Required for recursivity
|
||||
props: ['node', 'depth'],
|
||||
data: () => ({
|
||||
editingValue: '',
|
||||
}),
|
||||
computed: {
|
||||
leftPadding() {
|
||||
return `${this.depth * 15}px`;
|
||||
},
|
||||
childLeftPadding() {
|
||||
return `${(this.depth + 1) * 15}px`;
|
||||
},
|
||||
isSelected() {
|
||||
return store.getters['explorer/selectedNode'] === this.node;
|
||||
},
|
||||
isEditing() {
|
||||
return store.getters['explorer/editingNode'] === this.node;
|
||||
},
|
||||
isDragTarget() {
|
||||
return store.getters['explorer/dragTargetNode'] === this.node;
|
||||
},
|
||||
isDragTargetFolder() {
|
||||
return store.getters['explorer/dragTargetNodeFolder'] === this.node;
|
||||
},
|
||||
isOpen() {
|
||||
return store.state.explorer.openNodes[this.node.item.id] || this.node.isRoot;
|
||||
},
|
||||
newChild() {
|
||||
return store.getters['explorer/newChildNodeParent'] === this.node
|
||||
&& store.state.explorer.newChildNode;
|
||||
},
|
||||
newChildName: {
|
||||
get() {
|
||||
return store.state.explorer.newChildNode.item.name;
|
||||
},
|
||||
set(value) {
|
||||
store.commit('explorer/setNewItemName', value);
|
||||
},
|
||||
},
|
||||
editingNodeName: {
|
||||
get() {
|
||||
return store.getters['explorer/editingNode'].item.name;
|
||||
},
|
||||
set(value) {
|
||||
this.editingValue = value.trim();
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapMutations('explorer', [
|
||||
'setEditingId',
|
||||
]),
|
||||
...mapActions('explorer', [
|
||||
'setDragTarget',
|
||||
]),
|
||||
...mapActions('notification', [
|
||||
'info',
|
||||
]),
|
||||
select(id = this.node.item.id, doOpen = true) {
|
||||
const node = store.getters['explorer/nodeMap'][id];
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
store.commit('explorer/setSelectedId', id);
|
||||
if (doOpen) {
|
||||
// Prevent from freezing the UI while loading the file
|
||||
setTimeout(() => {
|
||||
if (node.isFolder) {
|
||||
store.commit('explorer/toggleOpenNode', id);
|
||||
} else if (store.state.file.currentId !== id) {
|
||||
store.commit('file/setCurrentId', id);
|
||||
badgeSvc.addBadge('switchFile');
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
async submitNewChild(cancel) {
|
||||
const { newChildNode } = store.state.explorer;
|
||||
if (!cancel && !newChildNode.isNil && newChildNode.item.name) {
|
||||
try {
|
||||
if (newChildNode.isFolder) {
|
||||
const item = await workspaceSvc.storeItem(newChildNode.item);
|
||||
this.select(item.id);
|
||||
badgeSvc.addBadge('createFolder');
|
||||
} else {
|
||||
const item = await workspaceSvc.createFile(newChildNode.item);
|
||||
this.select(item.id);
|
||||
badgeSvc.addBadge('createFile');
|
||||
}
|
||||
} catch (e) {
|
||||
// Cancel
|
||||
}
|
||||
}
|
||||
store.commit('explorer/setNewItem', null);
|
||||
},
|
||||
async submitEdit(cancel) {
|
||||
const { item, isFolder } = store.getters['explorer/editingNode'];
|
||||
const value = this.editingValue;
|
||||
this.setEditingId(null);
|
||||
if (!cancel && item.id && value && item.name !== value) {
|
||||
try {
|
||||
await workspaceSvc.storeItem({
|
||||
...item,
|
||||
name: value,
|
||||
});
|
||||
badgeSvc.addBadge(isFolder ? 'renameFolder' : 'renameFile');
|
||||
} catch (e) {
|
||||
// Cancel
|
||||
}
|
||||
}
|
||||
},
|
||||
setDragSourceId(evt) {
|
||||
if (this.node.noDrag) {
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
store.commit('explorer/setDragSourceId', this.node.item.id);
|
||||
// Fix for Firefox
|
||||
// See https://stackoverflow.com/a/3977637/1333165
|
||||
evt.dataTransfer.setData('Text', '');
|
||||
},
|
||||
copyPath() {
|
||||
let path = utils.getAbsoluteDir(this.node).replaceAll(' ', '%20');
|
||||
path = path.indexOf('/') === 0 ? path : `/${path}`;
|
||||
return this.node.isFolder ? path : `${path}.md`;
|
||||
},
|
||||
onDrop() {
|
||||
const sourceNode = store.getters['explorer/dragSourceNode'];
|
||||
const targetNode = store.getters['explorer/dragTargetNodeFolder'];
|
||||
this.setDragTarget();
|
||||
if (!sourceNode.isNil
|
||||
&& !targetNode.isNil
|
||||
&& sourceNode.item.id !== targetNode.item.id
|
||||
) {
|
||||
workspaceSvc.storeItem({
|
||||
...sourceNode.item,
|
||||
parentId: targetNode.item.id,
|
||||
});
|
||||
badgeSvc.addBadge(sourceNode.isFolder ? 'moveFolder' : 'moveFile');
|
||||
}
|
||||
},
|
||||
async onContextMenu(evt) {
|
||||
if (this.select(undefined, false)) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
const item = await store.dispatch('contextMenu/open', {
|
||||
coordinates: {
|
||||
left: evt.clientX,
|
||||
top: evt.clientY,
|
||||
},
|
||||
items: [{
|
||||
name: '新建文件',
|
||||
disabled: !this.node.isFolder || this.node.isTrash,
|
||||
perform: () => explorerSvc.newItem(false),
|
||||
}, {
|
||||
name: '新建文件夹',
|
||||
disabled: !this.node.isFolder || this.node.isTrash || this.node.isTemp,
|
||||
perform: () => explorerSvc.newItem(true),
|
||||
}, {
|
||||
type: 'separator',
|
||||
}, {
|
||||
name: '重命名',
|
||||
disabled: this.node.isTrash || this.node.isTemp,
|
||||
perform: () => this.setEditingId(this.node.item.id),
|
||||
}, {
|
||||
name: '删除',
|
||||
perform: () => explorerSvc.deleteItem(),
|
||||
}, {
|
||||
name: '复制路径',
|
||||
disabled: this.node.isTrash || this.node.isTemp,
|
||||
perform: () => this.$refs.copyId.click(),
|
||||
}],
|
||||
});
|
||||
if (item) {
|
||||
item.perform();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
$item-font-size: 14px;
|
||||
|
||||
.explorer-node--drag-target {
|
||||
background-color: rgba(0, 128, 255, 0.2);
|
||||
}
|
||||
|
||||
.explorer-node__item {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
font-size: $item-font-size;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
padding-right: 5px;
|
||||
|
||||
.explorer-node--selected > & {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
|
||||
.app--dark & {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.explorer__tree:focus & {
|
||||
background-color: #39f;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.explorer__tree--new-item & {
|
||||
opacity: 0.33;
|
||||
}
|
||||
|
||||
.explorer-node__location {
|
||||
float: right;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 2px 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.explorer-node--trash,
|
||||
.explorer-node--temp {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
|
||||
.app--dark & {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.explorer-node--folder > .explorer-node__item,
|
||||
.explorer-node--folder > .explorer-node__item-editor,
|
||||
.explorer-node__new-child--folder {
|
||||
&::before {
|
||||
content: '▹';
|
||||
position: absolute;
|
||||
margin-left: -13px;
|
||||
}
|
||||
}
|
||||
|
||||
.explorer-node--folder.explorer-node--open > .explorer-node__item,
|
||||
.explorer-node--folder.explorer-node--open > .explorer-node__item-editor {
|
||||
&::before {
|
||||
content: '▾';
|
||||
}
|
||||
}
|
||||
|
||||
$new-child-height: 25px;
|
||||
|
||||
.explorer-node__item-editor,
|
||||
.explorer-node__new-child {
|
||||
padding: 1px 10px;
|
||||
|
||||
.text-input {
|
||||
font-size: $item-font-size;
|
||||
padding: 2px;
|
||||
height: $new-child-height;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
395
src/components/FindReplace.vue
Normal file
@@ -0,0 +1,395 @@
|
||||
<template>
|
||||
<div class="find-replace" @keydown.esc.stop="onEscape">
|
||||
<button class="find-replace__close-button button not-tabbable" @click="close()" v-title="'关闭'">
|
||||
<icon-close></icon-close>
|
||||
</button>
|
||||
<div class="find-replace__row">
|
||||
<input type="text" class="find-replace__text-input find-replace__text-input--find text-input" @keydown.enter="find('forward')" v-model="findText">
|
||||
<div class="find-replace__find-stats">
|
||||
{{findPosition}} of {{findCount}}
|
||||
</div>
|
||||
<div class="flex flex--row flex--space-between">
|
||||
<div class="flex flex--row">
|
||||
<button class="find-replace__button find-replace__button--find-option button" :class="{'find-replace__button--on': findCaseSensitive}" @click="findCaseSensitive = !findCaseSensitive" title="Case sensitive">Aa</button>
|
||||
<button class="find-replace__button find-replace__button--find-option button" :class="{'find-replace__button--on': findUseRegexp}" @click="findUseRegexp = !findUseRegexp" title="Regular expression">.<sup>⁕</sup></button>
|
||||
</div>
|
||||
<div class="flex flex--row">
|
||||
<button class="find-replace__button button" @click="find('backward')">上一个</button>
|
||||
<button class="find-replace__button button" @click="find('forward')">下一个</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="type === 'replace'">
|
||||
<div class="find-replace__row">
|
||||
<input type="text" class="find-replace__text-input find-replace__text-input--replace text-input" @keydown.enter="replace" v-model="replaceText">
|
||||
</div>
|
||||
<div class="find-replace__row flex flex--row flex--end">
|
||||
<button class="find-replace__button button" @click="replace">替换</button>
|
||||
<button class="find-replace__button button" @click="replaceAll">全部替换</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from 'vuex';
|
||||
import editorSvc from '../services/editorSvc';
|
||||
import cledit from '../services/editor/cledit';
|
||||
import store from '../store';
|
||||
import EditorClassApplier from './common/EditorClassApplier';
|
||||
|
||||
const accessor = (fieldName, setterName) => ({
|
||||
get() {
|
||||
return store.state.findReplace[fieldName];
|
||||
},
|
||||
set(value) {
|
||||
store.commit(`findReplace/${setterName}`, value);
|
||||
},
|
||||
});
|
||||
|
||||
const computedLayoutSetting = key => ({
|
||||
get() {
|
||||
return store.getters['data/layoutSettings'][key];
|
||||
},
|
||||
set(value) {
|
||||
store.dispatch('data/patchLayoutSettings', {
|
||||
[key]: value,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
class DynamicClassApplier {
|
||||
constructor(cssClass, offset, silent) {
|
||||
this.startMarker = new cledit.Marker(offset.start);
|
||||
this.endMarker = new cledit.Marker(offset.end);
|
||||
editorSvc.clEditor.addMarker(this.startMarker);
|
||||
editorSvc.clEditor.addMarker(this.endMarker);
|
||||
if (!silent) {
|
||||
this.classApplier = new EditorClassApplier(
|
||||
[`find-replace-${this.startMarker.id}`, cssClass],
|
||||
() => ({
|
||||
start: this.startMarker.offset,
|
||||
end: this.endMarker.offset,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
clean = () => {
|
||||
editorSvc.clEditor.removeMarker(this.startMarker);
|
||||
editorSvc.clEditor.removeMarker(this.endMarker);
|
||||
if (this.classApplier) {
|
||||
this.classApplier.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
data: () => ({
|
||||
findCount: 0,
|
||||
findPosition: 0,
|
||||
}),
|
||||
computed: {
|
||||
...mapState('findReplace', [
|
||||
'type',
|
||||
'lastOpen',
|
||||
]),
|
||||
findText: accessor('findText', 'setFindText'),
|
||||
replaceText: accessor('replaceText', 'setReplaceText'),
|
||||
findCaseSensitive: computedLayoutSetting('findCaseSensitive'),
|
||||
findUseRegexp: computedLayoutSetting('findUseRegexp'),
|
||||
},
|
||||
methods: {
|
||||
highlightOccurrences() {
|
||||
const oldClassAppliers = {};
|
||||
Object.entries(this.classAppliers).forEach(([, classApplier]) => {
|
||||
const newKey = `${classApplier.startMarker.offset}:${classApplier.endMarker.offset}`;
|
||||
oldClassAppliers[newKey] = classApplier;
|
||||
});
|
||||
const offsetList = [];
|
||||
this.classAppliers = {};
|
||||
if (this.state !== 'destroyed' && this.findText) {
|
||||
try {
|
||||
this.searchRegex = this.findText;
|
||||
if (!this.findUseRegexp) {
|
||||
this.searchRegex = this.searchRegex.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
|
||||
}
|
||||
this.replaceRegex = new RegExp(this.searchRegex, this.findCaseSensitive ? 'm' : 'mi');
|
||||
this.searchRegex = new RegExp(this.searchRegex, this.findCaseSensitive ? 'gm' : 'gmi');
|
||||
editorSvc.clEditor.getContent().replace(this.searchRegex, (...params) => {
|
||||
const match = params[0];
|
||||
const offset = params[params.length - 2];
|
||||
offsetList.push({
|
||||
start: offset,
|
||||
end: offset + match.length,
|
||||
});
|
||||
});
|
||||
offsetList.forEach((offset, i) => {
|
||||
const key = `${offset.start}:${offset.end}`;
|
||||
this.classAppliers[key] = oldClassAppliers[key] || new DynamicClassApplier(
|
||||
'find-replace-highlighting',
|
||||
offset,
|
||||
i > 200,
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
if (this.state !== 'created') {
|
||||
this.find('selection');
|
||||
this.state = 'created';
|
||||
}
|
||||
}
|
||||
Object.entries(oldClassAppliers).forEach(([key, classApplier]) => {
|
||||
if (!this.classAppliers[key]) {
|
||||
classApplier.clean();
|
||||
if (classApplier === this.selectedClassApplier) {
|
||||
this.selectedClassApplier.child.clean();
|
||||
this.selectedClassApplier = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
this.findCount = offsetList.length;
|
||||
},
|
||||
unselectClassApplier() {
|
||||
if (this.selectedClassApplier) {
|
||||
this.selectedClassApplier.child.clean();
|
||||
this.selectedClassApplier.child = null;
|
||||
this.selectedClassApplier = null;
|
||||
}
|
||||
this.findPosition = 0;
|
||||
},
|
||||
find(mode = 'forward') {
|
||||
const { selectedClassApplier } = this;
|
||||
this.unselectClassApplier();
|
||||
const { selectionMgr } = editorSvc.clEditor;
|
||||
const startOffset = Math.min(selectionMgr.selectionStart, selectionMgr.selectionEnd);
|
||||
const endOffset = Math.max(selectionMgr.selectionStart, selectionMgr.selectionEnd);
|
||||
const keys = Object.keys(this.classAppliers);
|
||||
const finder = checker => (key) => {
|
||||
if (checker(this.classAppliers[key]) && selectedClassApplier !== this.classAppliers[key]) {
|
||||
this.selectedClassApplier = this.classAppliers[key];
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (mode === 'backward') {
|
||||
this.selectedClassApplier = this.classAppliers[keys[keys.length - 1]];
|
||||
keys.reverse().some(finder(classApplier => classApplier.startMarker.offset <= startOffset));
|
||||
} else if (mode === 'selection') {
|
||||
keys.some(finder(classApplier => classApplier.startMarker.offset === startOffset &&
|
||||
classApplier.endMarker.offset === endOffset));
|
||||
} else if (mode === 'forward') {
|
||||
this.selectedClassApplier = this.classAppliers[keys[0]];
|
||||
keys.some(finder(classApplier => classApplier.endMarker.offset >= endOffset));
|
||||
}
|
||||
if (this.selectedClassApplier) {
|
||||
selectionMgr.setSelectionStartEnd(
|
||||
this.selectedClassApplier.startMarker.offset,
|
||||
this.selectedClassApplier.endMarker.offset,
|
||||
);
|
||||
this.selectedClassApplier.child = new DynamicClassApplier('find-replace-selection', {
|
||||
start: this.selectedClassApplier.startMarker.offset,
|
||||
end: this.selectedClassApplier.endMarker.offset,
|
||||
});
|
||||
selectionMgr.updateCursorCoordinates(this.$el.parentNode.clientHeight);
|
||||
// Deduce the findPosition
|
||||
Object.keys(this.classAppliers).forEach((key, i) => {
|
||||
if (this.selectedClassApplier !== this.classAppliers[key]) {
|
||||
return false;
|
||||
}
|
||||
this.findPosition = i + 1;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
},
|
||||
replace() {
|
||||
if (this.searchRegex) {
|
||||
if (!this.selectedClassApplier) {
|
||||
this.find();
|
||||
return;
|
||||
}
|
||||
editorSvc.clEditor.replaceAll(
|
||||
this.replaceRegex,
|
||||
this.replaceText,
|
||||
this.selectedClassApplier.startMarker.offset,
|
||||
);
|
||||
this.$nextTick(() => this.find());
|
||||
}
|
||||
},
|
||||
replaceAll() {
|
||||
if (this.searchRegex) {
|
||||
editorSvc.clEditor.replaceAll(this.searchRegex, this.replaceText);
|
||||
}
|
||||
},
|
||||
close() {
|
||||
store.commit('findReplace/setType');
|
||||
},
|
||||
onEscape() {
|
||||
editorSvc.clEditor.focus();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.classAppliers = {};
|
||||
|
||||
// Highlight occurences
|
||||
this.debouncedHighlightOccurrences = cledit.Utils.debounce(
|
||||
() => this.highlightOccurrences(),
|
||||
25,
|
||||
);
|
||||
// Refresh highlighting when find text changes or changing options
|
||||
this.$watch(() => this.findText, this.debouncedHighlightOccurrences);
|
||||
this.$watch(() => this.findCaseSensitive, this.debouncedHighlightOccurrences);
|
||||
this.$watch(() => this.findUseRegexp, this.debouncedHighlightOccurrences);
|
||||
// Refresh highlighting when content changes
|
||||
editorSvc.clEditor.on('contentChanged', this.debouncedHighlightOccurrences);
|
||||
|
||||
// Last open changes trigger focus on text input and find occurence in selection
|
||||
this.$watch(() => this.lastOpen, () => {
|
||||
const elt = this.$el.querySelector(`.find-replace__text-input--${this.type}`);
|
||||
elt.focus();
|
||||
elt.setSelectionRange(0, this[`${this.type}Text`].length);
|
||||
// Highlight and find in selection
|
||||
this.state = null;
|
||||
this.debouncedHighlightOccurrences();
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
// Close on escape
|
||||
this.onKeyup = (evt) => {
|
||||
if (evt.which === 27) {
|
||||
// Esc key
|
||||
store.commit('findReplace/setType');
|
||||
}
|
||||
};
|
||||
window.addEventListener('keyup', this.onKeyup);
|
||||
|
||||
// Unselect class applier when focus is out of the panel
|
||||
this.onFocusIn = () => this.$el.contains(document.activeElement) ||
|
||||
setTimeout(() => this.unselectClassApplier(), 15);
|
||||
window.addEventListener('focusin', this.onFocusIn);
|
||||
},
|
||||
destroyed() {
|
||||
// Unregister listeners
|
||||
editorSvc.clEditor.off('contentChanged', this.debouncedHighlightOccurrences);
|
||||
window.removeEventListener('keyup', this.onKeyup);
|
||||
window.removeEventListener('focusin', this.onFocusIn);
|
||||
this.state = 'destroyed';
|
||||
this.debouncedHighlightOccurrences();
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/variables.scss';
|
||||
|
||||
.find-replace {
|
||||
padding: 0 35px 0 25px;
|
||||
}
|
||||
|
||||
.find-replace__row {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.find-replace__button {
|
||||
font-size: 15px;
|
||||
padding: 0 8px;
|
||||
line-height: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.find-replace__button--find-option {
|
||||
padding: 0;
|
||||
width: 28px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
text-transform: none;
|
||||
|
||||
.app--dark & {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
|
||||
.app--dark & {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.find-replace__button--on {
|
||||
color: rgba(0, 0, 0, 0.67);
|
||||
|
||||
.app--dark & {
|
||||
color: rgba(255, 255, 255, 0.67);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: rgba(0, 0, 0, 0.67);
|
||||
|
||||
.app--dark & {
|
||||
color: rgba(255, 255, 255, 0.67);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.find-replace__text-input {
|
||||
border: 1px solid transparent;
|
||||
padding: 2px 5px;
|
||||
height: 32px;
|
||||
|
||||
&:focus {
|
||||
border-color: $link-color;
|
||||
}
|
||||
}
|
||||
|
||||
.find-replace__close-button {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
padding: 2px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
|
||||
.app--dark & {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
|
||||
.app--dark & {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.find-replace__find-stats {
|
||||
text-align: right;
|
||||
font-size: 0.75em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.find-replace-highlighting {
|
||||
background-color: $highlighting-color;
|
||||
color: $editor-color-light !important;
|
||||
|
||||
.app--dark & {
|
||||
background-color: $dark-highlighting-color;
|
||||
}
|
||||
}
|
||||
|
||||
.find-replace-selection {
|
||||
background-color: $selection-highlighting-color;
|
||||
}
|
||||
</style>
|
||||
245
src/components/Layout.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<div class="layout" :class="{'layout--revision': revisionContent}">
|
||||
<div class="layout__panel flex flex--row" :class="{'flex--end': styles.showSideBar}">
|
||||
<div class="layout__panel layout__panel--explorer" v-show="styles.showExplorer" :aria-hidden="!styles.showExplorer" :style="{width: styles.layoutOverflow ? '100%' : constants.explorerWidth + 'px'}">
|
||||
<explorer></explorer>
|
||||
</div>
|
||||
<div class="layout__panel flex flex--column" tour-step-anchor="welcome,end" :style="{width: styles.innerWidth + 'px'}">
|
||||
<div class="layout__panel layout__panel--navigation-bar" v-show="styles.showNavigationBar" :style="{height: constants.navigationBarHeight + 'px'}">
|
||||
<navigation-bar></navigation-bar>
|
||||
</div>
|
||||
<div class="layout__panel flex flex--row" :style="{height: styles.innerHeight + 'px'}">
|
||||
<div class="layout__panel layout__panel--editor" :class="editTheme" v-show="styles.showEditor" :style="{width: (styles.editorWidth + styles.editorGutterWidth) + 'px', fontSize: styles.fontSize + 'px'}">
|
||||
<div class="gutter" :style="{left: styles.editorGutterLeft + 'px'}">
|
||||
<div class="gutter__background" v-if="styles.editorGutterWidth" :style="{width: styles.editorGutterWidth + 'px'}"></div>
|
||||
</div>
|
||||
<editor></editor>
|
||||
<editor-in-page-buttons v-if="editorShowInPageButtons"></editor-in-page-buttons>
|
||||
<div class="gutter" :style="{left: styles.editorGutterLeft + 'px'}">
|
||||
<sticky-comment v-if="styles.editorGutterWidth && stickyComment === 'top'"></sticky-comment>
|
||||
<current-discussion v-if="styles.editorGutterWidth"></current-discussion>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layout__panel layout__panel--button-bar" v-show="styles.showEditor" :style="{width: constants.buttonBarWidth + 'px'}">
|
||||
<button-bar></button-bar>
|
||||
</div>
|
||||
<div class="layout__panel layout__panel--preview" v-show="styles.showPreview" :style="{width: (styles.previewWidth + styles.previewGutterWidth) + 'px', fontSize: styles.fontSize + 'px'}">
|
||||
<div class="gutter" :style="{left: styles.previewGutterLeft + 'px'}">
|
||||
<div class="gutter__background" v-if="styles.previewGutterWidth" :style="{width: styles.previewGutterWidth + 'px'}"></div>
|
||||
</div>
|
||||
<preview></preview>
|
||||
<preview-in-page-buttons></preview-in-page-buttons>
|
||||
<div class="gutter" :style="{left: styles.previewGutterLeft + 'px'}">
|
||||
<sticky-comment v-if="styles.previewGutterWidth && stickyComment === 'top'"></sticky-comment>
|
||||
<current-discussion v-if="styles.previewGutterWidth"></current-discussion>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layout__panel layout__panel--find-replace" v-if="showFindReplace">
|
||||
<find-replace></find-replace>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layout__panel layout__panel--status-bar" v-show="styles.showStatusBar" :style="{height: constants.statusBarHeight + 'px'}">
|
||||
<status-bar></status-bar>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layout__panel layout__panel--side-bar" v-show="styles.showSideBar" :style="{width: styles.layoutOverflow ? '100%' : constants.sideBarWidth + 'px'}">
|
||||
<side-bar></side-bar>
|
||||
</div>
|
||||
</div>
|
||||
<tour v-if="!light && !layoutSettings.welcomeTourFinished"></tour>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapGetters, mapActions } from 'vuex';
|
||||
import NavigationBar from './NavigationBar';
|
||||
import ButtonBar from './ButtonBar';
|
||||
import StatusBar from './StatusBar';
|
||||
import Explorer from './Explorer';
|
||||
import SideBar from './SideBar';
|
||||
import Editor from './Editor';
|
||||
import Preview from './Preview';
|
||||
import Tour from './Tour';
|
||||
import EditorInPageButtons from './EditorInPageButtons';
|
||||
import PreviewInPageButtons from './PreviewInPageButtons';
|
||||
import StickyComment from './gutters/StickyComment';
|
||||
import CurrentDiscussion from './gutters/CurrentDiscussion';
|
||||
import FindReplace from './FindReplace';
|
||||
import editorSvc from '../services/editorSvc';
|
||||
import markdownConversionSvc from '../services/markdownConversionSvc';
|
||||
import store from '../store';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NavigationBar,
|
||||
ButtonBar,
|
||||
StatusBar,
|
||||
Explorer,
|
||||
SideBar,
|
||||
Editor,
|
||||
Preview,
|
||||
Tour,
|
||||
EditorInPageButtons,
|
||||
PreviewInPageButtons,
|
||||
StickyComment,
|
||||
CurrentDiscussion,
|
||||
FindReplace,
|
||||
},
|
||||
computed: {
|
||||
...mapState([
|
||||
'light',
|
||||
]),
|
||||
...mapState('content', [
|
||||
'revisionContent',
|
||||
]),
|
||||
...mapState('discussion', [
|
||||
'stickyComment',
|
||||
]),
|
||||
...mapGetters('layout', [
|
||||
'constants',
|
||||
'styles',
|
||||
]),
|
||||
...mapGetters('data', [
|
||||
'layoutSettings',
|
||||
]),
|
||||
...mapGetters('theme', [
|
||||
'currEditTheme',
|
||||
]),
|
||||
editTheme() {
|
||||
return `edit-theme--${this.currEditTheme || 'default'}`;
|
||||
},
|
||||
showFindReplace() {
|
||||
return !!store.state.findReplace.type;
|
||||
},
|
||||
editorShowInPageButtons() {
|
||||
return store.getters['data/computedSettings'].editor.showInPageButtons;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions('layout', [
|
||||
'updateBodySize',
|
||||
]),
|
||||
saveSelection: () => editorSvc.saveSelection(true),
|
||||
},
|
||||
created() {
|
||||
markdownConversionSvc.init(); // Needs to be inited before mount
|
||||
this.updateBodySize();
|
||||
window.addEventListener('resize', this.updateBodySize);
|
||||
window.addEventListener('keyup', this.saveSelection);
|
||||
window.addEventListener('mouseup', this.saveSelection);
|
||||
window.addEventListener('focusin', this.saveSelection);
|
||||
window.addEventListener('contextmenu', this.saveSelection);
|
||||
},
|
||||
mounted() {
|
||||
const editorElt = this.$el.querySelector('.editor__inner');
|
||||
const previewElt = this.$el.querySelector('.preview__inner-2');
|
||||
const tocElt = this.$el.querySelector('.toc__inner');
|
||||
editorSvc.init(editorElt, previewElt, tocElt);
|
||||
|
||||
// Focus on the editor every time reader mode is disabled
|
||||
const focus = () => {
|
||||
if (this.styles.showEditor) {
|
||||
editorSvc.clEditor.focus();
|
||||
}
|
||||
};
|
||||
setTimeout(focus, 100);
|
||||
this.$watch(() => this.styles.showEditor, focus);
|
||||
},
|
||||
destroyed() {
|
||||
window.removeEventListener('resize', this.updateStyle);
|
||||
window.removeEventListener('keyup', this.saveSelection);
|
||||
window.removeEventListener('mouseup', this.saveSelection);
|
||||
window.removeEventListener('focusin', this.saveSelection);
|
||||
window.removeEventListener('contextmenu', this.saveSelection);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/variables.scss';
|
||||
|
||||
.layout {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.layout__panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout__panel--navigation-bar {
|
||||
background-color: $navbar-bg;
|
||||
}
|
||||
|
||||
.layout__panel--status-bar {
|
||||
background-color: #007acc;
|
||||
}
|
||||
|
||||
.layout__panel--editor {
|
||||
background-color: $editor-background-light;
|
||||
|
||||
.app--dark & {
|
||||
background-color: $editor-background-dark;
|
||||
}
|
||||
|
||||
.gutter__background,
|
||||
.comment-list__current-discussion,
|
||||
.sticky-comment,
|
||||
.current-discussion {
|
||||
background-color: mix(#000, $editor-background-light, 6.7%);
|
||||
|
||||
.app--dark & {
|
||||
background-color: mix(#fff, $editor-background-dark, 6.7%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$preview-background-light: #f3f3f3;
|
||||
$preview-background-dark: #444;
|
||||
|
||||
.layout__panel--preview,
|
||||
.layout__panel--button-bar {
|
||||
background-color: $preview-background-light;
|
||||
|
||||
.app--dark & {
|
||||
background-color: $preview-background-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.layout__panel--preview {
|
||||
.gutter__background,
|
||||
.comment-list__current-discussion,
|
||||
.sticky-comment,
|
||||
.current-discussion {
|
||||
background-color: mix(#000, $preview-background-light, 6.7%);
|
||||
}
|
||||
}
|
||||
|
||||
.layout__panel--explorer,
|
||||
.layout__panel--side-bar {
|
||||
background-color: #ddd;
|
||||
|
||||
.app--dark & {
|
||||
background-color: #383c4a;
|
||||
}
|
||||
}
|
||||
|
||||
.layout__panel--find-replace {
|
||||
background-color: #e6e6e6;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 300px;
|
||||
height: auto;
|
||||
border-top-right-radius: $border-radius-base;
|
||||
|
||||
.app--dark & {
|
||||
background-color: #4d5160;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
489
src/components/Modal.vue
Normal file
@@ -0,0 +1,489 @@
|
||||
<template>
|
||||
<div class="modal" v-if="config" @keydown.esc.stop="onEscape" @keydown.tab="onTab" @focusin="onFocusInOut" @focusout="onFocusInOut">
|
||||
<!-- <div class="modal__sponsor-banner" v-if="!isSponsor">
|
||||
StackEdit is <a class="not-tabbable" target="_blank" href="https://github.com/mafgwo/stackedit/">open source</a>, please consider
|
||||
<a class="not-tabbable" href="javascript:void(0)" @click="sponsor">sponsoring</a> for just $5.
|
||||
</div> -->
|
||||
<component v-if="currentModalComponent" :is="currentModalComponent"></component>
|
||||
<modal-inner v-else aria-label="Dialog">
|
||||
<div class="modal__content" v-html="simpleModal.contentHtml(config)"></div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" v-if="simpleModal.rejectText" @click="config.reject()">{{simpleModal.rejectText}}</button>
|
||||
<button class="button button--resolve" v-if="simpleModal.resolveText" @click="config.resolve()">{{simpleModal.resolveText}}</button>
|
||||
<button v-for="(item, idx) in (simpleModal.resolveArray || [])" class="button button--resolve" @click="config.resolve(item.value)">{{item.text}}</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import simpleModals from '../data/simpleModals';
|
||||
import editorSvc from '../services/editorSvc';
|
||||
import syncSvc from '../services/syncSvc';
|
||||
import giteeHelper from '../services/providers/helpers/giteeHelper';
|
||||
import store from '../store';
|
||||
|
||||
import ModalInner from './modals/common/ModalInner';
|
||||
import FilePropertiesModal from './modals/FilePropertiesModal';
|
||||
import SettingsModal from './modals/SettingsModal';
|
||||
import TemplatesModal from './modals/TemplatesModal';
|
||||
import AboutModal from './modals/AboutModal';
|
||||
import HtmlExportModal from './modals/HtmlExportModal';
|
||||
import PdfExportModal from './modals/PdfExportModal';
|
||||
import PandocExportModal from './modals/PandocExportModal';
|
||||
import LinkModal from './modals/LinkModal';
|
||||
import ImageModal from './modals/ImageModal';
|
||||
import SyncManagementModal from './modals/SyncManagementModal';
|
||||
import PublishManagementModal from './modals/PublishManagementModal';
|
||||
import WorkspaceManagementModal from './modals/WorkspaceManagementModal';
|
||||
import AccountManagementModal from './modals/AccountManagementModal';
|
||||
import BadgeManagementModal from './modals/BadgeManagementModal';
|
||||
import SponsorModal from './modals/SponsorModal';
|
||||
import CommitMessageModal from './modals/CommitMessageModal';
|
||||
import WorkspaceImgPathModal from './modals/WorkspaceImgPathModal';
|
||||
import ChatGptModal from './modals/ChatGptModal';
|
||||
|
||||
// Providers
|
||||
import GooglePhotoModal from './modals/providers/GooglePhotoModal';
|
||||
import GoogleDriveAccountModal from './modals/providers/GoogleDriveAccountModal';
|
||||
import GoogleDriveSaveModal from './modals/providers/GoogleDriveSaveModal';
|
||||
import GoogleDriveWorkspaceModal from './modals/providers/GoogleDriveWorkspaceModal';
|
||||
import GoogleDrivePublishModal from './modals/providers/GoogleDrivePublishModal';
|
||||
import DropboxAccountModal from './modals/providers/DropboxAccountModal';
|
||||
import DropboxSaveModal from './modals/providers/DropboxSaveModal';
|
||||
import DropboxPublishModal from './modals/providers/DropboxPublishModal';
|
||||
import GithubAccountModal from './modals/providers/GithubAccountModal';
|
||||
import GithubOpenModal from './modals/providers/GithubOpenModal';
|
||||
import GithubSaveModal from './modals/providers/GithubSaveModal';
|
||||
import GithubWorkspaceModal from './modals/providers/GithubWorkspaceModal';
|
||||
import GithubPublishModal from './modals/providers/GithubPublishModal';
|
||||
import GithubImgStorageModal from './modals/providers/GithubImgStorageModal';
|
||||
import GistSyncModal from './modals/providers/GistSyncModal';
|
||||
import GistPublishModal from './modals/providers/GistPublishModal';
|
||||
import GiteeAccountModal from './modals/providers/GiteeAccountModal';
|
||||
import GiteeOpenModal from './modals/providers/GiteeOpenModal';
|
||||
import GiteeSaveModal from './modals/providers/GiteeSaveModal';
|
||||
import GiteeWorkspaceModal from './modals/providers/GiteeWorkspaceModal';
|
||||
import GiteePublishModal from './modals/providers/GiteePublishModal';
|
||||
import GiteeGistSyncModal from './modals/providers/GiteeGistSyncModal';
|
||||
import GiteeGistPublishModal from './modals/providers/GiteeGistPublishModal';
|
||||
import GitlabAccountModal from './modals/providers/GitlabAccountModal';
|
||||
import GitlabOpenModal from './modals/providers/GitlabOpenModal';
|
||||
import GitlabPublishModal from './modals/providers/GitlabPublishModal';
|
||||
import GitlabSaveModal from './modals/providers/GitlabSaveModal';
|
||||
import GitlabWorkspaceModal from './modals/providers/GitlabWorkspaceModal';
|
||||
import GiteaAccountModal from './modals/providers/GiteaAccountModal';
|
||||
import GiteaOpenModal from './modals/providers/GiteaOpenModal';
|
||||
import GiteaPublishModal from './modals/providers/GiteaPublishModal';
|
||||
import GiteaSaveModal from './modals/providers/GiteaSaveModal';
|
||||
import GiteaWorkspaceModal from './modals/providers/GiteaWorkspaceModal';
|
||||
import GiteaImgStorageModal from './modals/providers/GiteaImgStorageModal';
|
||||
import WordpressPublishModal from './modals/providers/WordpressPublishModal';
|
||||
import BloggerPublishModal from './modals/providers/BloggerPublishModal';
|
||||
import BloggerPagePublishModal from './modals/providers/BloggerPagePublishModal';
|
||||
import ZendeskAccountModal from './modals/providers/ZendeskAccountModal';
|
||||
import ZendeskPublishModal from './modals/providers/ZendeskPublishModal';
|
||||
import CouchdbWorkspaceModal from './modals/providers/CouchdbWorkspaceModal';
|
||||
import CouchdbCredentialsModal from './modals/providers/CouchdbCredentialsModal';
|
||||
import SmmsAccountModal from './modals/providers/SmmsAccountModal';
|
||||
import CustomAccountModal from './modals/providers/CustomAccountModal';
|
||||
|
||||
const getTabbables = container => container.querySelectorAll('a[href], button, .textfield, input[type=checkbox]')
|
||||
// Filter enabled and visible element
|
||||
.cl_filter(el => !el.disabled && el.offsetParent !== null && !el.classList.contains('not-tabbable'));
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ModalInner,
|
||||
FilePropertiesModal,
|
||||
SettingsModal,
|
||||
TemplatesModal,
|
||||
AboutModal,
|
||||
HtmlExportModal,
|
||||
PdfExportModal,
|
||||
PandocExportModal,
|
||||
LinkModal,
|
||||
ImageModal,
|
||||
SyncManagementModal,
|
||||
PublishManagementModal,
|
||||
WorkspaceManagementModal,
|
||||
AccountManagementModal,
|
||||
BadgeManagementModal,
|
||||
SponsorModal,
|
||||
CommitMessageModal,
|
||||
WorkspaceImgPathModal,
|
||||
ChatGptModal,
|
||||
// Providers
|
||||
GooglePhotoModal,
|
||||
GoogleDriveAccountModal,
|
||||
GoogleDriveSaveModal,
|
||||
GoogleDriveWorkspaceModal,
|
||||
GoogleDrivePublishModal,
|
||||
DropboxAccountModal,
|
||||
DropboxSaveModal,
|
||||
DropboxPublishModal,
|
||||
GithubAccountModal,
|
||||
GithubOpenModal,
|
||||
GithubSaveModal,
|
||||
GithubWorkspaceModal,
|
||||
GithubPublishModal,
|
||||
GithubImgStorageModal,
|
||||
GistSyncModal,
|
||||
GistPublishModal,
|
||||
GiteeAccountModal,
|
||||
GiteeOpenModal,
|
||||
GiteeSaveModal,
|
||||
GiteeWorkspaceModal,
|
||||
GiteePublishModal,
|
||||
GiteeGistSyncModal,
|
||||
GiteeGistPublishModal,
|
||||
GitlabAccountModal,
|
||||
GitlabOpenModal,
|
||||
GitlabPublishModal,
|
||||
GitlabSaveModal,
|
||||
GitlabWorkspaceModal,
|
||||
GiteaAccountModal,
|
||||
GiteaOpenModal,
|
||||
GiteaPublishModal,
|
||||
GiteaSaveModal,
|
||||
GiteaWorkspaceModal,
|
||||
GiteaImgStorageModal,
|
||||
WordpressPublishModal,
|
||||
BloggerPublishModal,
|
||||
BloggerPagePublishModal,
|
||||
ZendeskAccountModal,
|
||||
ZendeskPublishModal,
|
||||
CouchdbWorkspaceModal,
|
||||
CouchdbCredentialsModal,
|
||||
SmmsAccountModal,
|
||||
CustomAccountModal,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters([
|
||||
'isSponsor',
|
||||
]),
|
||||
...mapGetters('modal', [
|
||||
'config',
|
||||
]),
|
||||
currentModalComponent() {
|
||||
if (this.config.type) {
|
||||
let componentName = this.config.type[0].toUpperCase();
|
||||
componentName += this.config.type.slice(1);
|
||||
componentName += 'Modal';
|
||||
if (this.$options.components[componentName]) {
|
||||
return componentName;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
simpleModal() {
|
||||
return simpleModals[this.config.type] || {};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async sponsor() {
|
||||
try {
|
||||
if (!store.getters['workspace/sponsorToken']) {
|
||||
// User has to sign in
|
||||
await store.dispatch('modal/open', 'signInForSponsorship');
|
||||
await giteeHelper.signin();
|
||||
await syncSvc.afterSignIn();
|
||||
syncSvc.requestSync();
|
||||
}
|
||||
if (!store.getters.isSponsor) {
|
||||
await store.dispatch('modal/open', 'sponsor');
|
||||
}
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
onEscape() {
|
||||
this.config.reject();
|
||||
editorSvc.clEditor.focus();
|
||||
},
|
||||
onTab(evt) {
|
||||
const tabbables = getTabbables(this.$el);
|
||||
const firstTabbable = tabbables[0];
|
||||
const lastTabbable = tabbables[tabbables.length - 1];
|
||||
if (evt.shiftKey && firstTabbable === evt.target) {
|
||||
evt.preventDefault();
|
||||
lastTabbable.focus();
|
||||
} else if (!evt.shiftKey && lastTabbable === evt.target) {
|
||||
evt.preventDefault();
|
||||
firstTabbable.focus();
|
||||
}
|
||||
},
|
||||
onFocusInOut(evt) {
|
||||
const { parentNode } = evt.target;
|
||||
if (parentNode && parentNode.parentNode) {
|
||||
// Focus effect
|
||||
if (parentNode.classList.contains('form-entry__field')
|
||||
&& parentNode.parentNode.classList.contains('form-entry')) {
|
||||
parentNode.parentNode.classList.toggle(
|
||||
'form-entry--focused',
|
||||
evt.type === 'focusin',
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$watch(
|
||||
() => this.config,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
const tabbables = getTabbables(this.$el);
|
||||
if (tabbables[0]) {
|
||||
tabbables[0].focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/variables.scss';
|
||||
|
||||
.modal {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(160, 160, 160, 0.5);
|
||||
overflow: auto;
|
||||
|
||||
p {
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.modal__sponsor-banner {
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
color: darken($error-color, 10%);
|
||||
background-color: transparentize(lighten($error-color, 33%), 0.075);
|
||||
font-size: 0.9em;
|
||||
line-height: 1.33;
|
||||
text-align: center;
|
||||
padding: 0.25em 1em;
|
||||
}
|
||||
|
||||
.modal__inner-1 {
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
min-width: 320px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.modal__inner-2 {
|
||||
margin: 40px 10px 100px;
|
||||
background-color: #f8f8f8;
|
||||
padding: 50px 50px 40px;
|
||||
border-radius: $border-radius-base;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.app--dark & {
|
||||
background-color: #383c4a;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: $border-radius-base;
|
||||
width: 100%;
|
||||
background-image: linear-gradient(to left, #ffd700, #ffd700 23%, #a5c700 27%, #a5c700 48%, #ff8a00 52%, #ff8a00 73%, #66aefd 77%);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: $border-radius-base;
|
||||
width: 100%;
|
||||
background-image: linear-gradient(to right, #ffd700, #ffd700 23%, #a5c700 27%, #a5c700 48%, #ff8a00 52%, #ff8a00 73%, #66aefd 77%);
|
||||
}
|
||||
}
|
||||
|
||||
.modal__content > :first-child,
|
||||
.modal__content > .modal__image:first-child + * {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.modal__image {
|
||||
float: left;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin: 1.5em 1.2em 0.5em 0;
|
||||
|
||||
& + *::after {
|
||||
content: '';
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.4;
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
|
||||
.modal__sub-title {
|
||||
opacity: 0.6;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal__error {
|
||||
color: #de2c00;
|
||||
}
|
||||
|
||||
.modal__info {
|
||||
background-color: $info-bg;
|
||||
border-radius: $border-radius-base;
|
||||
margin: 1.2em 0;
|
||||
padding: 0.75em 1.25em;
|
||||
font-size: 0.95em;
|
||||
line-height: 1.6;
|
||||
|
||||
pre {
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.modal__info--multiline {
|
||||
padding-top: 0.1em;
|
||||
padding-bottom: 0.1em;
|
||||
}
|
||||
|
||||
.modal__button-bar {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.form-entry {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.form-entry__label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
color: #808080;
|
||||
|
||||
.form-entry--focused & {
|
||||
color: darken($link-color, 10%);
|
||||
}
|
||||
|
||||
.form-entry--error & {
|
||||
color: darken($error-color, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
.form-entry__label-info {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.form-entry__field {
|
||||
border: 1px solid #b0b0b0;
|
||||
border-radius: $border-radius-base;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.form-entry--focused & {
|
||||
border-color: $link-color;
|
||||
box-shadow: 0 0 0 2.5px transparentize($link-color, 0.67);
|
||||
}
|
||||
|
||||
.form-entry--error & {
|
||||
border-color: $error-color;
|
||||
box-shadow: 0 0 0 2.5px transparentize($error-color, 0.67);
|
||||
}
|
||||
}
|
||||
|
||||
.form-entry__actions {
|
||||
text-align: right;
|
||||
margin: 0.25em;
|
||||
}
|
||||
|
||||
.form-entry__button {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 6px;
|
||||
display: inline-block;
|
||||
background-color: transparent;
|
||||
opacity: 0.75;
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.form-entry__radio,
|
||||
.form-entry__checkbox {
|
||||
margin: 0.25em 1em;
|
||||
|
||||
input {
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
.form-entry__info {
|
||||
font-size: 0.75em;
|
||||
opacity: 0.67;
|
||||
line-height: 1.4;
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
border-bottom: 1px solid $hr-color;
|
||||
margin: 1em 0 2em;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs__tab {
|
||||
width: 50%;
|
||||
float: left;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
font-weight: 400;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.tabs__tab > a {
|
||||
width: 100%;
|
||||
text-decoration: none;
|
||||
padding: 0.67em 0.33em;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-top-left-radius: $border-radius-base;
|
||||
border-top-right-radius: $border-radius-base;
|
||||
color: $link-color;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.tabs__tab--active > a {
|
||||
border-bottom: 2px solid $link-color;
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
||||
543
src/components/NavigationBar.vue
Normal file
@@ -0,0 +1,543 @@
|
||||
<template>
|
||||
<nav class="navigation-bar" :class="{'navigation-bar--editor': styles.showEditor && !revisionContent, 'navigation-bar--light': light}">
|
||||
<!-- Explorer -->
|
||||
<div class="navigation-bar__inner navigation-bar__inner--left navigation-bar__inner--button">
|
||||
<button class="navigation-bar__button navigation-bar__button--close button" v-if="light" @click="close()" v-title="'关闭StackEdit'"><icon-check-circle></icon-check-circle></button>
|
||||
<button class="navigation-bar__button navigation-bar__button--explorer-toggler button" v-else tour-step-anchor="explorer" @click="toggleExplorer()" v-title="'切换资源管理器'"><icon-folder></icon-folder></button>
|
||||
</div>
|
||||
<!-- Side bar -->
|
||||
<div class="navigation-bar__inner navigation-bar__inner--right navigation-bar__inner--button">
|
||||
<button class="navigation-bar__button navigation-bar__button--theme button" v-title="'切换主题'" tour-step-anchor="theme" @click="switchTheme"><icon-switch-theme></icon-switch-theme></button>
|
||||
<a class="navigation-bar__button navigation-bar__button--stackedit button" v-if="light" href="app" target="_blank" v-title="'打开StackEdit'"><icon-provider provider-id="stackedit"></icon-provider></a>
|
||||
<button class="navigation-bar__button navigation-bar__button--stackedit button" v-else tour-step-anchor="menu" @click="toggleSideBar()" v-title="'切换侧边栏'"><icon-provider provider-id="stackedit"></icon-provider></button>
|
||||
</div>
|
||||
<div class="navigation-bar__inner navigation-bar__inner--right navigation-bar__inner--title flex flex--row">
|
||||
<!-- Spinner -->
|
||||
<div class="navigation-bar__spinner">
|
||||
<div v-if="!offline && showSpinner" class="spinner"></div>
|
||||
<icon-sync-off v-if="offline"></icon-sync-off>
|
||||
</div>
|
||||
<!-- Title -->
|
||||
<div class="navigation-bar__title navigation-bar__title--fake text-input"></div>
|
||||
<div class="navigation-bar__title navigation-bar__title--text text-input" :style="{width: titleWidth + 'px'}">{{title}}</div>
|
||||
<input class="navigation-bar__title navigation-bar__title--input text-input" :class="{'navigation-bar__title--focus': titleFocus, 'navigation-bar__title--scrolling': titleScrolling}" :style="{width: titleWidth + 'px'}" @focus="editTitle(true)" @blur="editTitle(false)" @keydown.enter="submitTitle(false)" @keydown.esc.stop="submitTitle(true)" @mouseenter="titleHover = true" @mouseleave="titleHover = false" v-model="title">
|
||||
<!-- Sync/Publish -->
|
||||
<div class="flex flex--row" :class="{'navigation-bar__hidden': styles.hideLocations}">
|
||||
<a class="navigation-bar__button navigation-bar__button--location button" :class="{'navigation-bar__button--blink': location.id === currentLocation.id}" v-for="location in syncLocations" :key="location.id" :href="location.url" target="_blank" v-title="'同步位置'"><icon-provider :provider-id="location.providerId"></icon-provider></a>
|
||||
<button class="navigation-bar__button navigation-bar__button--sync button" :disabled="!isSyncPossible || isSyncRequested || offline" @click="requestSync" v-title="'立即同步'"><icon-sync></icon-sync></button>
|
||||
<a class="navigation-bar__button navigation-bar__button--location button" :class="{'navigation-bar__button--blink': location.id === currentLocation.id}" v-for="location in publishLocations" :key="location.id" :href="location.url" target="_blank" v-title="'发布位置'"><icon-provider :provider-id="location.providerId"></icon-provider></a>
|
||||
<button class="navigation-bar__button navigation-bar__button--publish button" :disabled="!publishLocations.length || isPublishRequested || offline" @click="requestPublish" v-title="'立即发布'"><icon-upload></icon-upload></button>
|
||||
</div>
|
||||
<!-- Revision -->
|
||||
<div class="flex flex--row" v-if="revisionContent">
|
||||
<button class="navigation-bar__button navigation-bar__button--revision navigation-bar__button--restore button" @click="restoreRevision">恢复</button>
|
||||
<button class="navigation-bar__button navigation-bar__button--revision button" @click="setRevisionContent()" v-title="'关闭修订'"><icon-close></icon-close></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navigation-bar__inner navigation-bar__inner--edit-pagedownButtons">
|
||||
<button class="navigation-bar__button button" @click="undo" v-title="'回退'" :disabled="!canUndo"><icon-undo></icon-undo></button>
|
||||
<button class="navigation-bar__button button" @click="redo" v-title="'重做'" :disabled="!canRedo"><icon-redo></icon-redo></button>
|
||||
<div v-for="button in pagedownButtons" :key="button.method">
|
||||
<button class="navigation-bar__button button" v-if="button.method" @click="pagedownClick(button.method)" v-title="button.titleWithShortcut">
|
||||
<component :is="button.iconClass"></component>
|
||||
</button>
|
||||
<div class="navigation-bar__spacer" v-else></div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapMutations, mapGetters, mapActions } from 'vuex';
|
||||
import editorSvc from '../services/editorSvc';
|
||||
import syncSvc from '../services/syncSvc';
|
||||
import publishSvc from '../services/publishSvc';
|
||||
import animationSvc from '../services/animationSvc';
|
||||
import tempFileSvc from '../services/tempFileSvc';
|
||||
import utils from '../services/utils';
|
||||
import pagedownButtons from '../data/pagedownButtons';
|
||||
import store from '../store';
|
||||
import workspaceSvc from '../services/workspaceSvc';
|
||||
import badgeSvc from '../services/badgeSvc';
|
||||
|
||||
// According to mousetrap
|
||||
const mod = /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? 'Meta' : 'Ctrl';
|
||||
|
||||
const getShortcut = (method) => {
|
||||
let result = '';
|
||||
Object.entries(store.getters['data/computedSettings'].shortcuts).some(([keys, shortcut]) => {
|
||||
if (`${shortcut.method || shortcut}` === method) {
|
||||
result = keys.split('+').map(key => key.toLowerCase()).map((key) => {
|
||||
if (key === 'mod') {
|
||||
return mod;
|
||||
}
|
||||
// Capitalize
|
||||
return key && `${key[0].toUpperCase()}${key.slice(1)}`;
|
||||
}).join('+');
|
||||
}
|
||||
return result;
|
||||
});
|
||||
return result && ` – ${result}`;
|
||||
};
|
||||
|
||||
export default {
|
||||
data: () => ({
|
||||
mounted: false,
|
||||
title: '',
|
||||
titleFocus: false,
|
||||
titleHover: false,
|
||||
}),
|
||||
computed: {
|
||||
...mapState([
|
||||
'light',
|
||||
'offline',
|
||||
]),
|
||||
...mapState('queue', [
|
||||
'isSyncRequested',
|
||||
'isPublishRequested',
|
||||
'currentLocation',
|
||||
]),
|
||||
...mapState('layout', [
|
||||
'canUndo',
|
||||
'canRedo',
|
||||
]),
|
||||
...mapState('content', [
|
||||
'revisionContent',
|
||||
]),
|
||||
...mapGetters('layout', [
|
||||
'styles',
|
||||
]),
|
||||
...mapGetters('syncLocation', {
|
||||
syncLocations: 'current',
|
||||
}),
|
||||
...mapGetters('publishLocation', {
|
||||
publishLocations: 'current',
|
||||
}),
|
||||
pagedownButtons() {
|
||||
const buttonShowObj = store.getters['data/computedSettings'].editor.headButtons;
|
||||
return pagedownButtons.filter(it => buttonShowObj[it.method]).map(button => ({
|
||||
...button,
|
||||
titleWithShortcut: `${button.title}${getShortcut(button.method)}`,
|
||||
iconClass: `icon-${button.icon}`,
|
||||
}));
|
||||
},
|
||||
isSyncPossible() {
|
||||
return store.getters['workspace/syncToken'] ||
|
||||
store.getters['syncLocation/current'].length;
|
||||
},
|
||||
showSpinner() {
|
||||
return !store.state.queue.isEmpty;
|
||||
},
|
||||
titleWidth() {
|
||||
if (!this.mounted) {
|
||||
return 0;
|
||||
}
|
||||
this.titleFakeElt.textContent = this.title;
|
||||
const width = this.titleFakeElt.getBoundingClientRect().width + 2; // 2px for the caret
|
||||
return Math.min(width, this.styles.titleMaxWidth);
|
||||
},
|
||||
titleScrolling() {
|
||||
const result = this.titleHover && !this.titleFocus;
|
||||
if (this.titleInputElt) {
|
||||
if (result) {
|
||||
const scrollLeft = this.titleInputElt.scrollWidth - this.titleInputElt.offsetWidth;
|
||||
animationSvc.animate(this.titleInputElt)
|
||||
.scrollLeft(scrollLeft)
|
||||
.duration(scrollLeft * 10)
|
||||
.easing('inOut')
|
||||
.start();
|
||||
} else {
|
||||
animationSvc.animate(this.titleInputElt)
|
||||
.scrollLeft(0)
|
||||
.start();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
editCancelTrigger() {
|
||||
const current = store.getters['file/current'];
|
||||
return utils.serializeObject([
|
||||
current.id,
|
||||
current.name,
|
||||
]);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapMutations('content', [
|
||||
'setRevisionContent',
|
||||
]),
|
||||
...mapActions('content', [
|
||||
'restoreRevision',
|
||||
]),
|
||||
...mapActions('data', [
|
||||
'toggleExplorer',
|
||||
'toggleSideBar',
|
||||
]),
|
||||
undo() {
|
||||
return editorSvc.clEditor.undoMgr.undo();
|
||||
},
|
||||
redo() {
|
||||
return editorSvc.clEditor.undoMgr.redo();
|
||||
},
|
||||
requestSync() {
|
||||
if (this.isSyncPossible && !this.isSyncRequested) {
|
||||
syncSvc.requestSync(true);
|
||||
}
|
||||
},
|
||||
requestPublish() {
|
||||
if (this.publishLocations.length && !this.isPublishRequested) {
|
||||
publishSvc.requestPublish();
|
||||
}
|
||||
},
|
||||
switchTheme() {
|
||||
store.dispatch('data/switchThemeSetting');
|
||||
},
|
||||
pagedownClick(name) {
|
||||
if (store.getters['content/isCurrentEditable']) {
|
||||
const text = editorSvc.clEditor.getContent();
|
||||
editorSvc.pagedownEditor.uiManager.doClick(name);
|
||||
if (text !== editorSvc.clEditor.getContent()) {
|
||||
badgeSvc.addBadge('formatButtons');
|
||||
}
|
||||
}
|
||||
},
|
||||
async editTitle(toggle) {
|
||||
this.titleFocus = toggle;
|
||||
if (toggle) {
|
||||
this.titleInputElt.setSelectionRange(0, this.titleInputElt.value.length);
|
||||
} else {
|
||||
const title = this.title.trim();
|
||||
this.title = store.getters['file/current'].name;
|
||||
if (title && this.title !== title) {
|
||||
try {
|
||||
await workspaceSvc.storeItem({
|
||||
...store.getters['file/current'],
|
||||
name: title,
|
||||
});
|
||||
badgeSvc.addBadge('editCurrentFileName');
|
||||
} catch (e) {
|
||||
// Cancel
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
submitTitle(reset) {
|
||||
if (reset) {
|
||||
this.title = '';
|
||||
}
|
||||
this.titleInputElt.blur();
|
||||
},
|
||||
close() {
|
||||
tempFileSvc.close();
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.$watch(
|
||||
() => this.editCancelTrigger,
|
||||
() => {
|
||||
this.title = '';
|
||||
this.editTitle(false);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
},
|
||||
mounted() {
|
||||
this.titleFakeElt = this.$el.querySelector('.navigation-bar__title--fake');
|
||||
this.titleInputElt = this.$el.querySelector('.navigation-bar__title--input');
|
||||
this.mounted = true;
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/variables.scss';
|
||||
|
||||
.navigation-bar {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-top: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.navigation-bar__hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navigation-bar__inner--left {
|
||||
float: left;
|
||||
|
||||
&.navigation-bar__inner--button {
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-bar__inner--right {
|
||||
float: right;
|
||||
|
||||
/* prevent from seeing wrapped pagedownButtons */
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.navigation-bar__inner--button {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.navigation-bar__inner--edit-pagedownButtons {
|
||||
margin-left: 15px;
|
||||
|
||||
.navigation-bar__button,
|
||||
.navigation-bar__spacer {
|
||||
float: left;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-bar__inner--title * {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.navigation-bar__button,
|
||||
.navigation-bar__spacer {
|
||||
height: 36px;
|
||||
padding: 0 4px;
|
||||
|
||||
/* prevent from seeing wrapped pagedownButtons */
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.navigation-bar__button {
|
||||
width: 34px;
|
||||
padding: 0 7px;
|
||||
transition: opacity 0.25s;
|
||||
|
||||
.navigation-bar__inner--button & {
|
||||
padding: 0 4px;
|
||||
width: 38px;
|
||||
|
||||
&.navigation-bar__button--theme {
|
||||
width: 34px;
|
||||
padding: 0 7px;
|
||||
opacity: 0.85;
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.navigation-bar__button--stackedit {
|
||||
opacity: 0.85;
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-bar__button--revision {
|
||||
width: 38px;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-bar__button--restore {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.navigation-bar__title {
|
||||
margin: 0 4px;
|
||||
font-size: 21px;
|
||||
|
||||
.layout--revision & {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-bar__title,
|
||||
.navigation-bar__button {
|
||||
display: inline-block;
|
||||
color: $navbar-color;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.navigation-bar__button--sync,
|
||||
.navigation-bar__button--publish {
|
||||
padding: 0 6px;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.navigation-bar__button[disabled] {
|
||||
&,
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: $navbar-color;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-bar__title--input,
|
||||
.navigation-bar__button {
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: $navbar-hover-color;
|
||||
background-color: $navbar-hover-background;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-bar__button--location {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
padding: 2px;
|
||||
margin-top: 8px;
|
||||
opacity: 0.5;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-bar__button--blink {
|
||||
animation: blink 1s linear infinite;
|
||||
}
|
||||
|
||||
.navigation-bar__title--fake {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.navigation-bar__title--text {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.navigation-bar--editor & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-bar__title--input,
|
||||
.navigation-bar__inner--edit-pagedownButtons {
|
||||
display: none;
|
||||
|
||||
.navigation-bar--editor & {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-bar__button {
|
||||
display: none;
|
||||
|
||||
.navigation-bar__inner--button &,
|
||||
.navigation-bar--editor & {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-bar__button--revision {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.navigation-bar__button--close {
|
||||
color: lighten($link-color, 15%);
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: lighten($link-color, 25%);
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-bar__title--input {
|
||||
cursor: pointer;
|
||||
|
||||
&.navigation-bar__title--focus {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.navigation-bar--light & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
$r: 10px;
|
||||
$d: $r * 2;
|
||||
$b: $d/10;
|
||||
$t: 3000ms;
|
||||
|
||||
.navigation-bar__spinner {
|
||||
width: 24px;
|
||||
margin: 7px 0 0 8px;
|
||||
|
||||
.icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: transparentize($error-color, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: $d;
|
||||
height: $d;
|
||||
display: block;
|
||||
position: relative;
|
||||
border: $b solid transparentize($navbar-color, 0.5);
|
||||
border-radius: 50%;
|
||||
margin: 2px;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: $b;
|
||||
background-color: $navbar-color;
|
||||
border-radius: $b * 0.5;
|
||||
transform-origin: 50% 0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
height: $r * 0.4;
|
||||
left: $r - $b * 1.5;
|
||||
top: 50%;
|
||||
animation: spin $t linear infinite;
|
||||
}
|
||||
|
||||
&::after {
|
||||
height: $r * 0.6;
|
||||
left: $r - $b * 1.5;
|
||||
top: 50%;
|
||||
animation: spin $t/4 linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
72
src/components/Notification.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="notification">
|
||||
<div class="notification__item flex flex--row flex--align-center" v-for="(item, idx) in items" :key="idx">
|
||||
<div class="notification__icon flex flex--column flex--center">
|
||||
<icon-alert v-if="item.type === 'error'"></icon-alert>
|
||||
<icon-check-circle v-else-if="item.type === 'badge'"></icon-check-circle>
|
||||
<icon-information v-else></icon-information>
|
||||
</div>
|
||||
<div class="notification__content">
|
||||
{{item.content}}
|
||||
</div>
|
||||
<button class="notification__button button" v-if="item.type === 'confirm'" @click="item.reject">
|
||||
否
|
||||
</button>
|
||||
<button class="notification__button button" v-if="item.type === 'confirm'" @click="item.resolve">
|
||||
是
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from 'vuex';
|
||||
|
||||
export default {
|
||||
computed: mapState('notification', [
|
||||
'items',
|
||||
]),
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/variables.scss';
|
||||
|
||||
.notification {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.notification__item {
|
||||
margin: 10px;
|
||||
padding: 10px 15px;
|
||||
line-height: 1.4;
|
||||
background-color: #000;
|
||||
color: #fff;
|
||||
font-size: 0.9em;
|
||||
border-radius: $border-radius-base;
|
||||
}
|
||||
|
||||
.notification__icon {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
margin-right: 12px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.notification__button {
|
||||
color: $navbar-color;
|
||||
padding: 8px;
|
||||
flex: none;
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: $navbar-hover-color;
|
||||
background-color: $navbar-hover-background;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
181
src/components/Preview.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div class="preview">
|
||||
<div class="preview__inner-1" @click="onClick" @scroll="onScroll">
|
||||
<div class="preview__inner-2" :class="previewTheme" :style="{padding: styles.previewPadding}">
|
||||
</div>
|
||||
<div class="gutter" :style="{left: styles.previewGutterLeft + 'px'}">
|
||||
<comment-list v-if="styles.previewGutterWidth"></comment-list>
|
||||
<preview-new-discussion-button v-if="!isCurrentTemp"></preview-new-discussion-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!styles.showEditor" class="preview__corner">
|
||||
<button class="preview__button button" @click="toggleEditor(true)" v-title="'编辑文件'">
|
||||
<icon-pen></icon-pen>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
import { mapGetters, mapActions } from 'vuex';
|
||||
import CommentList from './gutters/CommentList';
|
||||
import PreviewNewDiscussionButton from './gutters/PreviewNewDiscussionButton';
|
||||
import store from '../store';
|
||||
|
||||
const appUri = `${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CommentList,
|
||||
PreviewNewDiscussionButton,
|
||||
},
|
||||
data: () => ({
|
||||
previewTop: true,
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters('file', [
|
||||
'isCurrentTemp',
|
||||
]),
|
||||
...mapGetters('theme', [
|
||||
'currPreviewTheme',
|
||||
]),
|
||||
...mapGetters('layout', [
|
||||
'styles',
|
||||
]),
|
||||
previewTheme() {
|
||||
return `preview-theme--${this.currPreviewTheme || 'default'}`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions('data', [
|
||||
'toggleEditor',
|
||||
]),
|
||||
onClick(evt) {
|
||||
let elt = evt.target;
|
||||
while (elt !== this.$el) {
|
||||
if (elt.href && elt.href.match(/^https?:\/\//)
|
||||
&& (!elt.hash || elt.href.slice(0, appUri.length) !== appUri)) {
|
||||
evt.preventDefault();
|
||||
const wnd = window.open(elt.href, '_blank');
|
||||
wnd.focus();
|
||||
return;
|
||||
}
|
||||
elt = elt.parentNode;
|
||||
}
|
||||
},
|
||||
onScroll(evt) {
|
||||
this.previewTop = evt.target.scrollTop < 10;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const previewElt = this.$el.querySelector('.preview__inner-2');
|
||||
const onDiscussionEvt = cb => (evt) => {
|
||||
let elt = evt.target;
|
||||
while (elt && elt !== previewElt) {
|
||||
if (elt.discussionId) {
|
||||
cb(elt.discussionId);
|
||||
return;
|
||||
}
|
||||
elt = elt.parentNode;
|
||||
}
|
||||
};
|
||||
|
||||
const classToggler = toggle => (discussionId) => {
|
||||
previewElt.getElementsByClassName(`discussion-preview-highlighting--${discussionId}`)
|
||||
.cl_each(elt => elt.classList.toggle('discussion-preview-highlighting--hover', toggle));
|
||||
document.getElementsByClassName(`comment--discussion-${discussionId}`)
|
||||
.cl_each(elt => elt.classList.toggle('comment--hover', toggle));
|
||||
};
|
||||
|
||||
previewElt.addEventListener('mouseover', onDiscussionEvt(classToggler(true)));
|
||||
previewElt.addEventListener('mouseout', onDiscussionEvt(classToggler(false)));
|
||||
previewElt.addEventListener('click', onDiscussionEvt((discussionId) => {
|
||||
store.commit('discussion/setCurrentDiscussionId', discussionId);
|
||||
}));
|
||||
|
||||
this.$watch(
|
||||
() => store.state.discussion.currentDiscussionId,
|
||||
(discussionId, oldDiscussionId) => {
|
||||
if (oldDiscussionId) {
|
||||
previewElt.querySelectorAll(`.discussion-preview-highlighting--${oldDiscussionId}`)
|
||||
.cl_each(elt => elt.classList.remove('discussion-preview-highlighting--selected'));
|
||||
}
|
||||
if (discussionId) {
|
||||
previewElt.querySelectorAll(`.discussion-preview-highlighting--${discussionId}`)
|
||||
.cl_each(elt => elt.classList.add('discussion-preview-highlighting--selected'));
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/variables.scss';
|
||||
|
||||
.preview,
|
||||
.preview__inner-1 {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview__inner-1 {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.preview__inner-2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview__inner-2 > :first-child > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
$corner-size: 110px;
|
||||
|
||||
.preview__corner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
border-top: $corner-size solid rgba(0, 0, 0, 0.075);
|
||||
border-left: $corner-size solid transparent;
|
||||
pointer-events: none;
|
||||
|
||||
.app--dark & {
|
||||
border-top-color: rgba(255, 255, 255, 0.075);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview__button {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 5px;
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
|
||||
.app--dark & {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: rgba(0, 0, 0, 0.33);
|
||||
background-color: transparent;
|
||||
|
||||
.app--dark & {
|
||||
color: rgba(255, 255, 255, 0.33);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
212
src/components/PreviewInPageButtons.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="preview-in-page-buttons">
|
||||
<ul>
|
||||
<li class="before">
|
||||
<icon-ellipsis></icon-ellipsis>
|
||||
</li>
|
||||
<li title="分享">
|
||||
<a href="javascript:void(0)" @click="share"><icon-share></icon-share></a>
|
||||
</li>
|
||||
<li title="切换预览主题">
|
||||
<dropdown-menu :selected="selectedTheme" :options="allThemes" :closeOnItemClick="false" @change="changeTheme">
|
||||
<icon-select-theme></icon-select-theme>
|
||||
</dropdown-menu>
|
||||
</li>
|
||||
<li title="Markdown语法帮助">
|
||||
<a href="javascript:void(0)" @click="showHelp"><icon-help-circle></icon-help-circle></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters, mapActions } from 'vuex';
|
||||
// import juice from 'juice';
|
||||
import store from '../store';
|
||||
import DropdownMenu from './common/DropdownMenu';
|
||||
import publishSvc from '../services/publishSvc';
|
||||
import giteeGistProvider from '../services/providers/giteeGistProvider';
|
||||
import gistProvider from '../services/providers/gistProvider';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DropdownMenu,
|
||||
},
|
||||
data: () => ({
|
||||
allThemes: [{
|
||||
name: '默认主题',
|
||||
value: 'default',
|
||||
}, {
|
||||
name: '凝夜紫',
|
||||
value: 'ningyezi',
|
||||
}, {
|
||||
name: '草原绿',
|
||||
value: 'caoyuangreen',
|
||||
}, {
|
||||
name: '雁栖湖',
|
||||
value: 'yanqihu',
|
||||
}, {
|
||||
name: '灵动蓝',
|
||||
value: 'activeblue',
|
||||
}, {
|
||||
name: '极客黑',
|
||||
value: 'jikebrack',
|
||||
}, {
|
||||
name: '极简黑',
|
||||
value: 'simplebrack',
|
||||
}, {
|
||||
name: '全栈蓝',
|
||||
value: 'allblue',
|
||||
}, {
|
||||
name: '自定义',
|
||||
value: 'custom',
|
||||
}],
|
||||
baseCss: '',
|
||||
sharing: false,
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters('theme', [
|
||||
'currPreviewTheme',
|
||||
'customPreviewThemeStyle',
|
||||
]),
|
||||
...mapGetters('publishLocation', {
|
||||
publishLocations: 'current',
|
||||
}),
|
||||
selectedTheme() {
|
||||
return {
|
||||
value: this.currPreviewTheme || 'default',
|
||||
};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions('data', [
|
||||
'toggleSideBar',
|
||||
]),
|
||||
async changeTheme(item) {
|
||||
await store.dispatch('theme/setPreviewTheme', item.value);
|
||||
// 如果自定义主题没内容 则弹出编辑区域
|
||||
if (item.value === 'custom' && !this.customPreviewThemeStyle) {
|
||||
this.toggleSideBar(true);
|
||||
store.dispatch('data/setSideBarPanel', 'previewTheme');
|
||||
}
|
||||
},
|
||||
showHelp() {
|
||||
this.toggleSideBar(true);
|
||||
store.dispatch('data/setSideBarPanel', 'help');
|
||||
},
|
||||
async share() {
|
||||
if (this.sharing) {
|
||||
store.dispatch('notification/info', '分享链接创建中...请稍后再试');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const currentFile = store.getters['file/current'];
|
||||
await store.dispatch('modal/open', { type: 'shareHtmlPre', name: currentFile.name });
|
||||
this.sharing = true;
|
||||
const mainToken = store.getters['workspace/mainWorkspaceToken'];
|
||||
if (!mainToken) {
|
||||
store.dispatch('notification/info', '登录主文档空间之后才可使用分享功能!');
|
||||
return;
|
||||
}
|
||||
let tempGistId = null;
|
||||
const isGithub = mainToken.providerId === 'githubAppData';
|
||||
const gistProviderId = isGithub ? 'gist' : 'giteegist';
|
||||
const filterLocations = this.publishLocations.filter(it => it.providerId === gistProviderId
|
||||
&& it.url && it.gistId);
|
||||
if (filterLocations.length > 0) {
|
||||
tempGistId = filterLocations[0].gistId;
|
||||
}
|
||||
const location = (isGithub ? gistProvider : giteeGistProvider).makeLocation(
|
||||
mainToken,
|
||||
`分享-${currentFile.name}`,
|
||||
true,
|
||||
null,
|
||||
);
|
||||
location.templateId = 'styledHtmlWithTheme';
|
||||
location.fileId = currentFile.id;
|
||||
location.gistId = tempGistId;
|
||||
const { gistId } = await publishSvc.publishLocationAndStore(location);
|
||||
const sharePage = mainToken.providerId === 'githubAppData' ? 'gistshare.html' : 'share.html';
|
||||
const url = `${window.location.protocol}//${window.location.host}/${sharePage}?id=${gistId}`;
|
||||
await store.dispatch('modal/open', { type: 'shareHtml', name: currentFile.name, url });
|
||||
} catch (err) {
|
||||
if (err) {
|
||||
store.dispatch('notification/error', err);
|
||||
}
|
||||
} finally {
|
||||
this.sharing = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/variables.scss';
|
||||
|
||||
.preview-in-page-buttons {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: -98px;
|
||||
height: 34px;
|
||||
padding: 5px;
|
||||
background-color: rgba(84, 96, 114, 0.4);
|
||||
border-radius: $border-radius-base;
|
||||
transition: 0.5s;
|
||||
display: flex;
|
||||
|
||||
.dropdown-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
right: 0;
|
||||
transition: 0.5s;
|
||||
background-color: #546072;
|
||||
|
||||
.dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu-items {
|
||||
bottom: 100%;
|
||||
top: unset;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding: 0;
|
||||
margin-left: 10px;
|
||||
line-height: 20px;
|
||||
|
||||
li {
|
||||
line-height: 16px;
|
||||
width: 16px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-right: 10px;
|
||||
|
||||
.icon {
|
||||
color: #fff;
|
||||
opacity: 0.7;
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.before {
|
||||
margin-left: -16px;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
191
src/components/SideBar.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="side-bar flex flex--column">
|
||||
<div class="side-title flex flex--row">
|
||||
<button v-if="panel !== 'menu'" class="side-title__button button" @click="setPanel('menu')" v-title="'主菜单'">
|
||||
<icon-dots-horizontal></icon-dots-horizontal>
|
||||
</button>
|
||||
<div class="side-title__title">
|
||||
{{panelName}}
|
||||
</div>
|
||||
<button class="side-title__button button" @click="toggleSideBar(false)" v-title="'关闭侧边栏'">
|
||||
<icon-close></icon-close>
|
||||
</button>
|
||||
</div>
|
||||
<div class="side-bar__inner">
|
||||
<main-menu v-if="panel === 'menu'"></main-menu>
|
||||
<workspaces-menu v-else-if="panel === 'workspaces'"></workspaces-menu>
|
||||
<sync-menu v-else-if="panel === 'sync'"></sync-menu>
|
||||
<publish-menu v-else-if="panel === 'publish'"></publish-menu>
|
||||
<history-menu v-else-if="panel === 'history'"></history-menu>
|
||||
<export-menu v-else-if="panel === 'export'"></export-menu>
|
||||
<import-export-menu v-else-if="panel === 'importExport'"></import-export-menu>
|
||||
<workspace-backup-menu v-else-if="panel === 'workspaceBackups'"></workspace-backup-menu>
|
||||
<div v-else-if="panel === 'help'" class="side-bar__panel side-bar__panel--help">
|
||||
<pre class="markdown-highlighting" v-html="markdownSample"></pre>
|
||||
</div>
|
||||
<edit-theme-menu v-else-if="panel === 'editTheme'"></edit-theme-menu>
|
||||
<preview-theme-menu v-else-if="panel === 'previewTheme'"></preview-theme-menu>
|
||||
<div class="side-bar__panel side-bar__panel--toc" :class="{'side-bar__panel--hidden': panel !== 'toc'}">
|
||||
<toc>
|
||||
</toc>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions } from 'vuex';
|
||||
import Toc from './Toc';
|
||||
import MainMenu from './menus/MainMenu';
|
||||
import WorkspacesMenu from './menus/WorkspacesMenu';
|
||||
import SyncMenu from './menus/SyncMenu';
|
||||
import PublishMenu from './menus/PublishMenu';
|
||||
import HistoryMenu from './menus/HistoryMenu';
|
||||
import ImportExportMenu from './menus/ImportExportMenu';
|
||||
import WorkspaceBackupMenu from './menus/WorkspaceBackupMenu';
|
||||
import EditThemeMenu from './menus/EditThemeMenu';
|
||||
import PreviewThemeMenu from './menus/PreviewThemeMenu';
|
||||
import markdownSample from '../data/markdownSample.md';
|
||||
import markdownConversionSvc from '../services/markdownConversionSvc';
|
||||
import store from '../store';
|
||||
|
||||
const panelNames = {
|
||||
menu: '菜单',
|
||||
workspaces: '文档空间',
|
||||
help: 'Markdown 帮助',
|
||||
toc: '目录',
|
||||
sync: '同步',
|
||||
publish: '发布',
|
||||
history: '文件历史',
|
||||
importExport: '导入/导出',
|
||||
workspaceBackups: '文档空间备份',
|
||||
editTheme: '编辑区主题',
|
||||
previewTheme: '预览区主题',
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Toc,
|
||||
MainMenu,
|
||||
WorkspacesMenu,
|
||||
SyncMenu,
|
||||
PublishMenu,
|
||||
HistoryMenu,
|
||||
ImportExportMenu,
|
||||
WorkspaceBackupMenu,
|
||||
EditThemeMenu,
|
||||
PreviewThemeMenu,
|
||||
},
|
||||
data: () => ({
|
||||
markdownSample: markdownConversionSvc.highlight(markdownSample),
|
||||
}),
|
||||
computed: {
|
||||
panel() {
|
||||
if (store.state.light) {
|
||||
return null; // No menu in light mode
|
||||
}
|
||||
const result = store.getters['data/layoutSettings'].sideBarPanel;
|
||||
return panelNames[result] ? result : 'menu';
|
||||
},
|
||||
panelName() {
|
||||
return panelNames[this.panel];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions('data', [
|
||||
'toggleSideBar',
|
||||
]),
|
||||
...mapActions('data', {
|
||||
setPanel: 'setSideBarPanel',
|
||||
}),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/variables.scss';
|
||||
|
||||
.side-bar {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
|
||||
hr {
|
||||
margin: 10px 40px;
|
||||
display: none;
|
||||
border-top: 1px solid $hr-color;
|
||||
}
|
||||
|
||||
* + hr {
|
||||
display: block;
|
||||
}
|
||||
|
||||
hr + hr {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.textfield {
|
||||
font-size: 14px;
|
||||
height: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.side-bar__inner {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.side-bar__panel {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.side-bar__panel--hidden {
|
||||
left: 1000px;
|
||||
}
|
||||
|
||||
.side-bar__panel--menu {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.side-bar__panel--help {
|
||||
padding: 0 10px 0 20px;
|
||||
|
||||
pre {
|
||||
font-size: 0.9em;
|
||||
font-variant-ligatures: no-common-ligatures;
|
||||
line-height: 1.25;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.code,
|
||||
.img,
|
||||
.imgref,
|
||||
.cl-toc {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.side-bar__info {
|
||||
padding: 10px;
|
||||
margin: -10px -10px 10px;
|
||||
background-color: $info-bg;
|
||||
font-size: 0.95em;
|
||||
|
||||
p {
|
||||
margin: 10px 15px;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.67;
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
22
src/components/SplashScreen.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="splash-screen">
|
||||
<div class="splash-screen__inner logo-background"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.splash-screen {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 25px;
|
||||
}
|
||||
|
||||
.splash-screen__inner {
|
||||
margin: 0 auto;
|
||||
max-width: 600px;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
132
src/components/StatusBar.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="stat-panel panel no-overflow">
|
||||
<div class="stat-panel__block stat-panel__block--left" v-if="styles.showEditor">
|
||||
<span class="stat-panel__block-name">
|
||||
Markdown
|
||||
<span v-if="textSelection">selection</span>
|
||||
</span>
|
||||
<span v-for="stat in textStats" :key="stat.id">
|
||||
<span class="stat-panel__value">{{stat.value}}</span> {{stat.name}}
|
||||
</span>
|
||||
<span class="stat-panel__value">第 {{line}} 行, 第 {{column}} 列</span>
|
||||
</div>
|
||||
<div class="stat-panel__block stat-panel__block--right">
|
||||
<span class="stat-panel__block-name">
|
||||
HTML
|
||||
<span v-if="htmlSelection">selection</span>
|
||||
</span>
|
||||
<span v-for="stat in htmlStats" :key="stat.id">
|
||||
<span class="stat-panel__value">{{stat.value}}</span> {{stat.name}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import editorSvc from '../services/editorSvc';
|
||||
import utils from '../services/utils';
|
||||
|
||||
class Stat {
|
||||
constructor(name, regex) {
|
||||
this.id = utils.uid();
|
||||
this.name = name;
|
||||
this.regex = new RegExp(regex, 'gm');
|
||||
this.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
data: () => ({
|
||||
textSelection: false,
|
||||
htmlSelection: false,
|
||||
line: 0,
|
||||
column: 0,
|
||||
textStats: [
|
||||
new Stat('字符', '[\\s\\S]'),
|
||||
new Stat('字数', '\\S'),
|
||||
new Stat('行数', '\n'),
|
||||
],
|
||||
htmlStats: [
|
||||
new Stat('字数', '\\S'),
|
||||
new Stat('段落', '\\S.*'),
|
||||
],
|
||||
}),
|
||||
computed: mapGetters('layout', [
|
||||
'styles',
|
||||
]),
|
||||
created() {
|
||||
editorSvc.$on('sectionList', () => this.computeText());
|
||||
editorSvc.$on('selectionRange', () => this.computeText());
|
||||
editorSvc.$on('previewCtx', () => this.computeHtml());
|
||||
editorSvc.$on('previewSelectionRange', () => this.computeHtml());
|
||||
},
|
||||
|
||||
methods: {
|
||||
computeText() {
|
||||
setTimeout(() => {
|
||||
this.textSelection = false;
|
||||
let text = editorSvc.clEditor.getContent();
|
||||
const beforeText = text.slice(0, editorSvc.clEditor.selectionMgr.selectionEnd);
|
||||
const beforeLines = beforeText.split('\n');
|
||||
this.line = beforeLines.length;
|
||||
this.column = beforeLines.pop().length;
|
||||
|
||||
const selectedText = editorSvc.clEditor.selectionMgr.getSelectedText();
|
||||
if (selectedText) {
|
||||
this.textSelection = true;
|
||||
text = selectedText;
|
||||
}
|
||||
this.textStats.forEach((stat) => {
|
||||
stat.value = (text.match(stat.regex) || []).length;
|
||||
});
|
||||
}, 10);
|
||||
},
|
||||
computeHtml() {
|
||||
setTimeout(() => {
|
||||
let text;
|
||||
if (editorSvc.previewSelectionRange) {
|
||||
text = `${editorSvc.previewSelectionRange}`;
|
||||
}
|
||||
this.htmlSelection = true;
|
||||
if (!text) {
|
||||
this.htmlSelection = false;
|
||||
({ text } = editorSvc.previewCtx);
|
||||
}
|
||||
if (text != null) {
|
||||
this.htmlStats.forEach((stat) => {
|
||||
stat.value = (text.match(stat.regex) || []).length;
|
||||
});
|
||||
}
|
||||
}, 10);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.stat-panel {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stat-panel__block {
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.stat-panel__block--left {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.stat-panel__block--right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.stat-panel__value {
|
||||
font-weight: 600;
|
||||
margin-left: 5px;
|
||||
}
|
||||
</style>
|
||||
162
src/components/Toc.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="toc">
|
||||
<div class="toc__mask" :style="{top: (maskY - 5) + 'px'}"></div>
|
||||
<div class="toc__inner"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import editorSvc from '../services/editorSvc';
|
||||
|
||||
export default {
|
||||
data: () => ({
|
||||
maskY: 0,
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters('layout', [
|
||||
'styles',
|
||||
]),
|
||||
},
|
||||
mounted() {
|
||||
const tocElt = this.$el.querySelector('.toc__inner');
|
||||
|
||||
// TOC click behaviour
|
||||
let isMousedown;
|
||||
function onClick(e) {
|
||||
if (!isMousedown) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const y = e.clientY - tocElt.getBoundingClientRect().top;
|
||||
|
||||
editorSvc.previewCtx.sectionDescList.some((sectionDesc) => {
|
||||
if (y >= sectionDesc.tocDimension.endOffset) {
|
||||
return false;
|
||||
}
|
||||
const posInSection = (y - sectionDesc.tocDimension.startOffset)
|
||||
/ (sectionDesc.tocDimension.height || 1);
|
||||
const editorScrollTop = sectionDesc.editorDimension.startOffset
|
||||
+ (sectionDesc.editorDimension.height * posInSection);
|
||||
editorSvc.editorElt.parentNode.scrollTop = editorScrollTop;
|
||||
const previewScrollTop = sectionDesc.previewDimension.startOffset
|
||||
+ (sectionDesc.previewDimension.height * posInSection);
|
||||
editorSvc.previewElt.parentNode.scrollTop = previewScrollTop;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
tocElt.addEventListener('mouseup', () => {
|
||||
isMousedown = false;
|
||||
});
|
||||
tocElt.addEventListener('mouseleave', () => {
|
||||
isMousedown = false;
|
||||
});
|
||||
tocElt.addEventListener('mousedown', (e) => {
|
||||
isMousedown = e.which === 1;
|
||||
onClick(e);
|
||||
});
|
||||
tocElt.addEventListener('mousemove', (e) => {
|
||||
onClick(e);
|
||||
});
|
||||
|
||||
// Change mask postion on scroll
|
||||
const updateMaskY = () => {
|
||||
const scrollPosition = editorSvc.getScrollPosition();
|
||||
if (scrollPosition) {
|
||||
const sectionDesc = editorSvc.previewCtxMeasured.sectionDescList[scrollPosition.sectionIdx];
|
||||
this.maskY = sectionDesc.tocDimension.startOffset +
|
||||
(scrollPosition.posInSection * sectionDesc.tocDimension.height);
|
||||
}
|
||||
};
|
||||
|
||||
this.$nextTick(() => {
|
||||
editorSvc.editorElt.parentNode.addEventListener('scroll', () => {
|
||||
if (this.styles.showEditor) {
|
||||
updateMaskY();
|
||||
}
|
||||
});
|
||||
editorSvc.previewElt.parentNode.addEventListener('scroll', () => {
|
||||
if (!this.styles.showEditor) {
|
||||
updateMaskY();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.toc__inner {
|
||||
position: relative;
|
||||
color: rgba(0, 0, 0, 0.67);
|
||||
cursor: pointer;
|
||||
font-size: 9px;
|
||||
padding: 10px 20px 40px;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
.app--dark & {
|
||||
color: rgba(255, 255, 255, 0.67);
|
||||
}
|
||||
|
||||
* {
|
||||
font-weight: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cl-toc-section {
|
||||
h1,
|
||||
h2 {
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0.5rem 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0.33rem 0;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0.22rem 0;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
h5 {
|
||||
margin: 0.11rem 0;
|
||||
margin-left: 32px;
|
||||
}
|
||||
|
||||
h6 {
|
||||
margin: 0;
|
||||
margin-left: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toc__mask {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 35px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
pointer-events: none;
|
||||
|
||||
.app--dark & {
|
||||
color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
254
src/components/Tour.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div class="tour" @keydown.esc.stop="skip">
|
||||
<div class="tour-step" :class="'tour-step--' + step" :style="stepStyle">
|
||||
<div class="tour-step__inner" v-if="step === 'welcome'">
|
||||
<h2>欢迎回来!</h2>
|
||||
<p>新的<b>StackEdit中文版</b>在这里!</p>
|
||||
<p>请单击<b>下一步</b>快速浏览。</p>
|
||||
<div class="tour-step__button-bar">
|
||||
<button class="button" @click="finish">跳过</button>
|
||||
<button class="button button--resolve" @click="next">下一步</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tour-step__inner" v-else-if="step === 'editor'">
|
||||
<h2>您的Markdown编辑器</h2>
|
||||
<p>StackEdit中文版实时将Markdown转换为HTML。</p>
|
||||
<p>点击 <icon-side-preview></icon-side-preview> 切换侧边预览</p>
|
||||
<div class="tour-step__button-bar">
|
||||
<button class="button" @click="finish">跳过</button>
|
||||
<button class="button button--resolve" @click="next">下一步</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tour-step__inner" v-else-if="step === 'explorer'">
|
||||
<h2>文件资源管理器</h2>
|
||||
<p>StackEdit中文版可以管理文档空间中的多个文件和文件夹。</p>
|
||||
<p>点击 <icon-folder></icon-folder> 打开文件资源管理器。</p>
|
||||
<div class="tour-step__button-bar">
|
||||
<button class="button" @click="finish">跳过</button>
|
||||
<button class="button button--resolve" @click="next">下一步</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tour-step__inner" v-else-if="step === 'menu'">
|
||||
<h2>切换侧边栏!</h2>
|
||||
<p>StackEdit中文版还可以同步和发布文件,管理协作文档空间...</p>
|
||||
<p>点击 <icon-provider provider-id="stackedit"></icon-provider> 浏览菜单。</p>
|
||||
<div class="tour-step__button-bar">
|
||||
<button class="button" @click="finish">跳过</button>
|
||||
<button class="button button--resolve" @click="next">下一步</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tour-step__inner" v-else-if="step === 'theme'">
|
||||
<h2>切换主题!</h2>
|
||||
<p>StackEdit中文版可以切换亮/暗主题。</p>
|
||||
<p>点击 <icon-switch-theme></icon-switch-theme> 切换主题。</p>
|
||||
<div class="tour-step__button-bar">
|
||||
<button class="button" @click="finish">跳过</button>
|
||||
<button class="button button--resolve" @click="next">下一步</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tour-step__inner" v-else-if="step === 'end'">
|
||||
<h2>Enjoy!</h2>
|
||||
<p>如果您喜欢StackEdit中文版,请在<a href="https://gitee.com/mafgwo/stackedit">Gitee仓库</a>上点一下Star,谢谢!</p>
|
||||
<div class="tour-step__button-bar">
|
||||
<button class="button button--resolve" @click="finish">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import store from '../store';
|
||||
|
||||
const steps = [
|
||||
'welcome',
|
||||
'editor',
|
||||
'explorer',
|
||||
'menu',
|
||||
'theme',
|
||||
'end',
|
||||
];
|
||||
|
||||
export default {
|
||||
data: () => ({
|
||||
stepIdx: 0,
|
||||
stepStyles: {},
|
||||
}),
|
||||
computed: {
|
||||
step() {
|
||||
return steps[this.stepIdx];
|
||||
},
|
||||
stepStyle() {
|
||||
return this.stepStyles[this.step] || {};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
updatePositions() {
|
||||
document.querySelectorAll('[tour-step-anchor]').cl_each((anchorElt) => {
|
||||
const anchorRect = anchorElt.getBoundingClientRect();
|
||||
const anchorSteps = (anchorElt.getAttribute('tour-step-anchor') || '').split(',');
|
||||
anchorSteps.forEach((step) => {
|
||||
const style = {
|
||||
top: `${anchorRect.top + (anchorRect.height / 2)}px`,
|
||||
left: `${anchorRect.left + (anchorRect.width / 2)}px`,
|
||||
};
|
||||
switch (step) {
|
||||
case 'welcome':
|
||||
case 'end': {
|
||||
style.top = `${anchorRect.top}px`;
|
||||
break;
|
||||
}
|
||||
case 'editor':
|
||||
case 'menu':
|
||||
case 'theme': {
|
||||
style.left = `${anchorRect.left}px`;
|
||||
break;
|
||||
}
|
||||
case 'explorer': {
|
||||
style.left = `${anchorRect.left + anchorRect.width}px`;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
Vue.set(this.stepStyles, step, style);
|
||||
});
|
||||
});
|
||||
},
|
||||
finish() {
|
||||
store.dispatch('data/patchLayoutSettings', {
|
||||
welcomeTourFinished: true,
|
||||
});
|
||||
},
|
||||
next() {
|
||||
this.stepIdx += 1;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$watch(
|
||||
() => store.getters['layout/styles'],
|
||||
() => this.updatePositions(),
|
||||
{ immediate: true },
|
||||
);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/variables.scss';
|
||||
|
||||
.tour {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.tour-step {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
$tour-step-background: transparentize(mix(#f3f3f3, $selection-highlighting-color, 75%), 0.025);
|
||||
$tour-step-darkbackground: transparentize(mix(#4d4d4d, $selection-highlighting-color, 75%), 0.025);
|
||||
$tour-step-width: 240px;
|
||||
|
||||
.tour-step__inner {
|
||||
position: absolute;
|
||||
background-color: $tour-step-background;
|
||||
padding: 1.5em;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.33;
|
||||
width: $tour-step-width;
|
||||
text-align: center;
|
||||
border-radius: $border-radius-base;
|
||||
|
||||
.app--dark & {
|
||||
background-color: $tour-step-darkbackground;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.icon,
|
||||
.icon-provider {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
vertical-align: bottom;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.tour-step--welcome &,
|
||||
.tour-step--end & {
|
||||
left: -$tour-step-width/2;
|
||||
top: 36px;
|
||||
border-bottom-right-radius: 0;
|
||||
|
||||
&::before {
|
||||
bottom: -10px;
|
||||
right: 0;
|
||||
border-top: 10px solid $tour-step-background;
|
||||
border-left: 10px solid transparent;
|
||||
|
||||
.app--dark & {
|
||||
border-top: 10px solid $tour-step-darkbackground;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tour-step--editor &,
|
||||
.tour-step--menu &,
|
||||
.tour-step--theme & {
|
||||
right: 15px;
|
||||
border-top-right-radius: 0;
|
||||
|
||||
&::before {
|
||||
top: 0;
|
||||
right: -10px;
|
||||
border-top: 10px solid $tour-step-background;
|
||||
border-right: 10px solid transparent;
|
||||
|
||||
.app--dark & {
|
||||
border-top: 10px solid $tour-step-darkbackground;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tour-step--explorer & {
|
||||
left: 15px;
|
||||
border-top-left-radius: 0;
|
||||
|
||||
&::before {
|
||||
top: 0;
|
||||
left: -10px;
|
||||
border-top: 10px solid $tour-step-background;
|
||||
border-left: 10px solid transparent;
|
||||
|
||||
.app--dark & {
|
||||
border-top: 10px solid $tour-step-darkbackground;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tour-step__button-bar {
|
||||
margin-top: 1.5em;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
|
||||
.button {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
39
src/components/UserImage.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="user-image" :style="{backgroundImage: url}">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import userSvc from '../services/userSvc';
|
||||
import store from '../store';
|
||||
|
||||
export default {
|
||||
props: ['userId'],
|
||||
computed: {
|
||||
sanitizedUserId() {
|
||||
return userSvc.sanitizeUserId(this.userId);
|
||||
},
|
||||
url() {
|
||||
const userInfo = store.state.userInfo.itemsById[this.sanitizedUserId];
|
||||
return userInfo && userInfo.imageUrl && `url('${userInfo.imageUrl}')`;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
sanitizedUserId: {
|
||||
handler: sanitizedUserId => userSvc.addUserId(sanitizedUserId),
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.user-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
}
|
||||
</style>
|
||||
27
src/components/UserName.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<span class="user-name">{{name}}</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import userSvc from '../services/userSvc';
|
||||
import store from '../store';
|
||||
|
||||
export default {
|
||||
props: ['userId'],
|
||||
computed: {
|
||||
sanitizedUserId() {
|
||||
return userSvc.sanitizeUserId(this.userId);
|
||||
},
|
||||
name() {
|
||||
const userInfo = store.state.userInfo.itemsById[this.sanitizedUserId];
|
||||
return userInfo ? userInfo.name : 'Someone';
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
sanitizedUserId: {
|
||||
handler: sanitizedUserId => userSvc.addUserId(sanitizedUserId),
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
137
src/components/common/DropdownMenu.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<span class="dropdown-menu">
|
||||
<span ref="slotInfo" @click="toggleMenu()" class="dropdown-toggle">
|
||||
<slot></slot>
|
||||
</span>
|
||||
<ul class="dropdown-menu-items" :style="dropdownStyle" v-if="showMenu">
|
||||
<li v-for="(option, idx) in options" :key="idx">
|
||||
<a href="javascript:void(0)" :class="{selected: option.value === selectedOption.value}" @click="updateOption(option)">
|
||||
{{ option.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import store from '../../store';
|
||||
|
||||
export default {
|
||||
data: () => ({
|
||||
selectedOption: {
|
||||
value: '',
|
||||
name: '',
|
||||
},
|
||||
showMenu: false,
|
||||
}),
|
||||
props: {
|
||||
options: {
|
||||
type: [Array, Object],
|
||||
},
|
||||
selected: {},
|
||||
closeOnOutsideClick: {
|
||||
type: [Boolean],
|
||||
default: true,
|
||||
},
|
||||
closeOnItemClick: {
|
||||
type: [Boolean],
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.selectedOption = this.selected;
|
||||
if (this.closeOnOutsideClick) {
|
||||
document.addEventListener('click', this.clickHandler);
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('click', this.clickHandler);
|
||||
},
|
||||
computed: {
|
||||
dropdownStyle() {
|
||||
const height = store.state.layout.bodyHeight;
|
||||
return `max-height: ${height * 0.7}px;`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
updateOption(option) {
|
||||
this.selectedOption = option;
|
||||
if (this.closeOnItemClick) {
|
||||
this.showMenu = false;
|
||||
}
|
||||
this.$emit('change', option);
|
||||
},
|
||||
toggleMenu() {
|
||||
this.showMenu = !this.showMenu;
|
||||
},
|
||||
clickHandler(event) {
|
||||
const { target } = event;
|
||||
const { $el } = this;
|
||||
if (!$el.contains(target)) {
|
||||
this.showMenu = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selected(val) {
|
||||
this.selectedOption = val;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.dropdown-menu {
|
||||
.dropdown-menu-items {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
float: left;
|
||||
min-width: 160px;
|
||||
max-height: 450px;
|
||||
overflow-y: scroll;
|
||||
padding: 5px 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
font-size: 15px;
|
||||
background-color: #666;
|
||||
border: 1px solid #666;
|
||||
border-radius: 4px;
|
||||
-webkit-box-shadow: 0 6px 12px rgb(0, 0, 0 / 18%);
|
||||
box-shadow: 0 6px 12px rgb(0, 0, 0 / 18%);
|
||||
background-clip: padding-box;
|
||||
|
||||
li {
|
||||
width: 100%;
|
||||
display: list-item;
|
||||
margin: 0;
|
||||
text-align: -webkit-match-parent;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
clear: both;
|
||||
font-weight: normal;
|
||||
line-height: 1.45;
|
||||
white-space: nowrap;
|
||||
color: #eee;
|
||||
padding: 5px 20px;
|
||||
border-top: 1px solid transparent;
|
||||
border-bottom: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
background-color: rgb(82, 82, 82);
|
||||
}
|
||||
}
|
||||
|
||||
a.selected {
|
||||
background: #74b936 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
89
src/components/common/EditorClassApplier.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import cledit from '../../services/editor/cledit';
|
||||
import editorSvc from '../../services/editorSvc';
|
||||
import utils from '../../services/utils';
|
||||
|
||||
let savedSelection = null;
|
||||
const nextTickCbs = [];
|
||||
const nextTickExecCbs = cledit.Utils.debounce(() => {
|
||||
while (nextTickCbs.length) {
|
||||
nextTickCbs.shift()();
|
||||
}
|
||||
if (savedSelection) {
|
||||
editorSvc.clEditor.selectionMgr.setSelectionStartEnd(
|
||||
savedSelection.start,
|
||||
savedSelection.end,
|
||||
);
|
||||
}
|
||||
savedSelection = null;
|
||||
});
|
||||
|
||||
const nextTick = (cb) => {
|
||||
nextTickCbs.push(cb);
|
||||
nextTickExecCbs();
|
||||
};
|
||||
|
||||
const nextTickRestoreSelection = () => {
|
||||
savedSelection = {
|
||||
start: editorSvc.clEditor.selectionMgr.selectionStart,
|
||||
end: editorSvc.clEditor.selectionMgr.selectionEnd,
|
||||
};
|
||||
nextTickExecCbs();
|
||||
};
|
||||
|
||||
export default class EditorClassApplier {
|
||||
constructor(classGetter, offsetGetter, properties) {
|
||||
this.classGetter = typeof classGetter === 'function' ? classGetter : () => classGetter;
|
||||
this.offsetGetter = typeof offsetGetter === 'function' ? offsetGetter : () => offsetGetter;
|
||||
this.properties = properties || {};
|
||||
this.eltCollection = editorSvc.editorElt.getElementsByClassName(this.classGetter()[0]);
|
||||
this.lastEltCount = this.eltCollection.length;
|
||||
|
||||
this.restoreClass = () => {
|
||||
if (!this.eltCollection.length || this.eltCollection.length !== this.lastEltCount) {
|
||||
this.removeClass();
|
||||
this.applyClass();
|
||||
}
|
||||
};
|
||||
|
||||
editorSvc.clEditor.on('contentChanged', this.restoreClass);
|
||||
nextTick(() => this.restoreClass());
|
||||
}
|
||||
|
||||
applyClass() {
|
||||
if (!this.stopped) {
|
||||
const offset = this.offsetGetter();
|
||||
if (offset && offset.start !== offset.end) {
|
||||
const range = editorSvc.clEditor.selectionMgr.createRange(
|
||||
Math.min(offset.start, offset.end),
|
||||
Math.max(offset.start, offset.end),
|
||||
);
|
||||
const properties = {
|
||||
...this.properties,
|
||||
className: this.classGetter().join(' '),
|
||||
};
|
||||
editorSvc.clEditor.watcher.noWatch(() => {
|
||||
utils.wrapRange(range, properties);
|
||||
});
|
||||
if (editorSvc.clEditor.selectionMgr.hasFocus()) {
|
||||
nextTickRestoreSelection();
|
||||
}
|
||||
this.lastEltCount = this.eltCollection.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeClass() {
|
||||
editorSvc.clEditor.watcher.noWatch(() => {
|
||||
utils.unwrapRange(this.eltCollection);
|
||||
});
|
||||
if (editorSvc.clEditor.selectionMgr.hasFocus()) {
|
||||
nextTickRestoreSelection();
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
editorSvc.clEditor.off('contentChanged', this.restoreClass);
|
||||
nextTick(() => this.removeClass());
|
||||
this.stopped = true;
|
||||
}
|
||||
}
|
||||
82
src/components/common/PreviewClassApplier.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import cledit from '../../services/editor/cledit';
|
||||
import editorSvc from '../../services/editorSvc';
|
||||
import utils from '../../services/utils';
|
||||
|
||||
const nextTickCbs = [];
|
||||
const nextTickExecCbs = cledit.Utils.debounce(() => {
|
||||
while (nextTickCbs.length) {
|
||||
nextTickCbs.shift()();
|
||||
}
|
||||
});
|
||||
|
||||
const nextTick = (cb) => {
|
||||
nextTickCbs.push(cb);
|
||||
nextTickExecCbs();
|
||||
};
|
||||
|
||||
export default class PreviewClassApplier {
|
||||
constructor(classGetter, offsetGetter, properties) {
|
||||
this.classGetter = typeof classGetter === 'function' ? classGetter : () => classGetter;
|
||||
this.offsetGetter = typeof offsetGetter === 'function' ? offsetGetter : () => offsetGetter;
|
||||
this.properties = properties || {};
|
||||
this.eltCollection = editorSvc.previewElt.getElementsByClassName(this.classGetter()[0]);
|
||||
this.lastEltCount = this.eltCollection.length;
|
||||
|
||||
this.restoreClass = () => {
|
||||
if (!editorSvc.previewCtxWithDiffs) {
|
||||
this.removeClass();
|
||||
} else if (!this.eltCollection.length || this.eltCollection.length !== this.lastEltCount) {
|
||||
this.removeClass();
|
||||
this.applyClass();
|
||||
}
|
||||
};
|
||||
|
||||
editorSvc.$on('previewCtxWithDiffs', this.restoreClass);
|
||||
nextTick(() => this.restoreClass());
|
||||
}
|
||||
|
||||
applyClass() {
|
||||
if (!this.stopped) {
|
||||
const offset = this.offsetGetter();
|
||||
if (offset) {
|
||||
const offsetStart = editorSvc.getPreviewOffset(
|
||||
offset.start,
|
||||
editorSvc.previewCtx.sectionDescList,
|
||||
);
|
||||
const offsetEnd = editorSvc.getPreviewOffset(
|
||||
offset.end,
|
||||
editorSvc.previewCtx.sectionDescList,
|
||||
);
|
||||
if (offsetStart != null && offsetEnd != null && offsetStart !== offsetEnd) {
|
||||
const start = cledit.Utils.findContainer(
|
||||
editorSvc.previewElt,
|
||||
Math.min(offsetStart, offsetEnd),
|
||||
);
|
||||
const end = cledit.Utils.findContainer(
|
||||
editorSvc.previewElt,
|
||||
Math.max(offsetStart, offsetEnd),
|
||||
);
|
||||
const range = document.createRange();
|
||||
range.setStart(start.container, start.offsetInContainer);
|
||||
range.setEnd(end.container, end.offsetInContainer);
|
||||
const properties = {
|
||||
...this.properties,
|
||||
className: this.classGetter().join(' '),
|
||||
};
|
||||
utils.wrapRange(range, properties);
|
||||
this.lastEltCount = this.eltCollection.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeClass() {
|
||||
utils.unwrapRange(this.eltCollection);
|
||||
}
|
||||
|
||||
stop() {
|
||||
editorSvc.$off('previewCtxWithDiffs', this.restoreClass);
|
||||
nextTick(() => this.removeClass());
|
||||
this.stopped = true;
|
||||
}
|
||||
}
|
||||
80
src/components/common/vueGlobals.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import Vue from 'vue';
|
||||
import Clipboard from 'clipboard';
|
||||
import timeSvc from '../../services/timeSvc';
|
||||
import store from '../../store';
|
||||
|
||||
// Global directives
|
||||
Vue.directive('focus', {
|
||||
inserted(el) {
|
||||
el.focus();
|
||||
const { value } = el;
|
||||
if (value && el.setSelectionRange) {
|
||||
el.setSelectionRange(0, value.length);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const setVisible = (el, value) => {
|
||||
el.style.display = value ? '' : 'none';
|
||||
if (value) {
|
||||
el.removeAttribute('aria-hidden');
|
||||
} else {
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
};
|
||||
Vue.directive('show', {
|
||||
bind(el, { value }) {
|
||||
setVisible(el, value);
|
||||
},
|
||||
update(el, { value, oldValue }) {
|
||||
if (value !== oldValue) {
|
||||
setVisible(el, value);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const setElTitle = (el, title) => {
|
||||
el.title = title;
|
||||
el.setAttribute('aria-label', title);
|
||||
};
|
||||
Vue.directive('title', {
|
||||
bind(el, { value }) {
|
||||
setElTitle(el, value);
|
||||
},
|
||||
update(el, { value, oldValue }) {
|
||||
if (value !== oldValue) {
|
||||
setElTitle(el, value);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Clipboard directive
|
||||
const createClipboard = (el, value) => {
|
||||
el.seClipboard = new Clipboard(el, { text: () => value });
|
||||
};
|
||||
const destroyClipboard = (el) => {
|
||||
if (el.seClipboard) {
|
||||
el.seClipboard.destroy();
|
||||
el.seClipboard = null;
|
||||
}
|
||||
};
|
||||
Vue.directive('clipboard', {
|
||||
bind(el, { value }) {
|
||||
createClipboard(el, value);
|
||||
},
|
||||
update(el, { value, oldValue }) {
|
||||
if (value !== oldValue) {
|
||||
destroyClipboard(el);
|
||||
createClipboard(el, value);
|
||||
}
|
||||
},
|
||||
unbind(el) {
|
||||
destroyClipboard(el);
|
||||
},
|
||||
});
|
||||
|
||||
// Global filters
|
||||
Vue.filter('formatTime', time =>
|
||||
// Access the time counter for reactive refresh
|
||||
timeSvc.format(time, store.state.timeCounter));
|
||||
|
||||
83
src/components/gutters/Comment.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="comment">
|
||||
<div class="comment__header flex flex--row flex--space-between flex--align-center">
|
||||
<div class="comment__user flex flex--row flex--align-center">
|
||||
<div class="comment__user-image">
|
||||
<user-image :user-id="comment.sub"></user-image>
|
||||
</div>
|
||||
<button class="comment__remove-button button" v-title="'删除评论'" @click="removeComment">
|
||||
<icon-delete></icon-delete>
|
||||
</button>
|
||||
<user-name :user-id="comment.sub"></user-name>
|
||||
</div>
|
||||
<div class="comment__created">{{comment.created | formatTime}}</div>
|
||||
</div>
|
||||
<div class="comment__text">
|
||||
<div class="comment__text-inner" v-html="text"></div>
|
||||
</div>
|
||||
<div class="comment__buttons flex flex--row flex--end" v-if="showReply">
|
||||
<button class="comment__button button" @click="setIsCommenting(true)">评论</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapMutations } from 'vuex';
|
||||
import UserImage from '../UserImage';
|
||||
import UserName from '../UserName';
|
||||
import editorSvc from '../../services/editorSvc';
|
||||
import htmlSanitizer from '../../libs/htmlSanitizer';
|
||||
import store from '../../store';
|
||||
import badgeSvc from '../../services/badgeSvc';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
UserImage,
|
||||
UserName,
|
||||
},
|
||||
props: ['comment'],
|
||||
computed: {
|
||||
showReply() {
|
||||
return this.comment === store.getters['discussion/currentDiscussionLastComment'] &&
|
||||
!store.state.discussion.isCommenting;
|
||||
},
|
||||
text() {
|
||||
return htmlSanitizer.sanitizeHtml(editorSvc.converter.render(this.comment.text));
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapMutations('discussion', [
|
||||
'setIsCommenting',
|
||||
]),
|
||||
async removeComment() {
|
||||
try {
|
||||
await store.dispatch('modal/open', 'commentDeletion');
|
||||
store.dispatch('discussion/cleanCurrentFile', { filterComment: this.comment });
|
||||
badgeSvc.addBadge('removeComment');
|
||||
} catch (e) {
|
||||
// Cancel
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const isSticky = this.$el.parentNode.classList.contains('sticky-comment');
|
||||
if (isSticky) {
|
||||
const commentId = store.getters['discussion/currentDiscussionLastCommentId'];
|
||||
const scrollerElt = this.$el.querySelector('.comment__text-inner');
|
||||
|
||||
let scrollerMirrorElt;
|
||||
const getScrollerMirrorElt = () => {
|
||||
if (!scrollerMirrorElt) {
|
||||
scrollerMirrorElt = document.querySelector(`.comment-list .comment--${commentId} .comment__text-inner`);
|
||||
}
|
||||
return scrollerMirrorElt || { scrollTop: 0 };
|
||||
};
|
||||
|
||||
scrollerElt.scrollTop = getScrollerMirrorElt().scrollTop;
|
||||
scrollerElt.addEventListener('scroll', () => {
|
||||
getScrollerMirrorElt().scrollTop = scrollerElt.scrollTop;
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
362
src/components/gutters/CommentList.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<div class="comment-list" :class="stickyComment && 'comment-list--' + stickyComment" :style="{width: constants.gutterWidth + 'px'}">
|
||||
<comment v-for="(comment, discussionId) in currentFileDiscussionLastComments" :key="discussionId" v-if="comment.discussionId !== currentDiscussionId" :comment="comment" class="comment--last" :class="'comment--discussion-' + discussionId" :style="{top: tops[discussionId] + 'px'}" @click.native="setCurrentDiscussionId(discussionId)"></comment>
|
||||
<div class="comment-list__current-discussion" :style="{top: tops.current + 'px'}">
|
||||
<comment v-for="(comment, id) in currentDiscussionComments" :key="id" :comment="comment" :class="'comment--' + id"></comment>
|
||||
<new-comment v-if="isCommenting"></new-comment>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapGetters, mapMutations } from 'vuex';
|
||||
import Comment from './Comment';
|
||||
import NewComment from './NewComment';
|
||||
import editorSvc from '../../services/editorSvc';
|
||||
import store from '../../store';
|
||||
import utils from '../../services/utils';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Comment,
|
||||
NewComment,
|
||||
},
|
||||
data: () => ({
|
||||
tops: {},
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters('layout', [
|
||||
'constants',
|
||||
'styles',
|
||||
]),
|
||||
...mapState('discussion', [
|
||||
'currentDiscussionId',
|
||||
'isCommenting',
|
||||
'newCommentText',
|
||||
'stickyComment',
|
||||
]),
|
||||
...mapGetters('discussion', [
|
||||
'newDiscussion',
|
||||
'currentDiscussion',
|
||||
'currentFileDiscussions',
|
||||
'currentFileDiscussionLastComments',
|
||||
'currentDiscussionComments',
|
||||
'currentDiscussionLastCommentId',
|
||||
]),
|
||||
updateTopsTrigger() {
|
||||
return utils.serializeObject([
|
||||
this.styles,
|
||||
this.currentFileDiscussionLastComments,
|
||||
this.currentDiscussionComments,
|
||||
this.currentDiscussionId,
|
||||
this.isCommenting,
|
||||
]);
|
||||
},
|
||||
updateStickyTrigger() {
|
||||
return utils.serializeObject([
|
||||
this.updateTopsTrigger,
|
||||
this.newCommentText,
|
||||
]);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapMutations('discussion', [
|
||||
'setCurrentDiscussionId',
|
||||
]),
|
||||
updateTops() {
|
||||
const layoutSettings = store.getters['data/layoutSettings'];
|
||||
const minTop = -2;
|
||||
let minCommentTop = minTop;
|
||||
const getTop = (discussion, commentElt1, commentElt2, isCurrent) => {
|
||||
const firstElt = commentElt1 || commentElt2;
|
||||
const secondElt = commentElt1 && commentElt2;
|
||||
const coordinates = layoutSettings.showEditor
|
||||
? editorSvc.clEditor.selectionMgr.getCoordinates(discussion.end)
|
||||
: editorSvc.getPreviewOffsetCoordinates(editorSvc.getPreviewOffset(discussion.end));
|
||||
let commentTop = minTop;
|
||||
if (coordinates) {
|
||||
commentTop = (coordinates.top + coordinates.height) - 80;
|
||||
}
|
||||
let top = commentTop;
|
||||
if (isCurrent) {
|
||||
top -= firstElt.offsetTop + 2; // 2 for top border
|
||||
}
|
||||
if (top < minTop) {
|
||||
commentTop += minTop - top;
|
||||
top = minTop;
|
||||
}
|
||||
if (commentTop < minCommentTop) {
|
||||
top += minCommentTop - commentTop;
|
||||
commentTop = minCommentTop;
|
||||
}
|
||||
minCommentTop = commentTop + firstElt.offsetHeight + 60;
|
||||
if (secondElt) {
|
||||
minCommentTop += secondElt.offsetHeight;
|
||||
}
|
||||
return top;
|
||||
};
|
||||
|
||||
// Get the discussion top coordinates
|
||||
const tops = {};
|
||||
const discussions = this.currentFileDiscussions;
|
||||
Object.entries(discussions)
|
||||
.sort(([, discussion1], [, discussion2]) => discussion1.end - discussion2.end)
|
||||
.forEach(([discussionId, discussion]) => {
|
||||
if (discussion === this.currentDiscussion || discussion === this.newDiscussion) {
|
||||
tops.current = getTop(
|
||||
discussion,
|
||||
this.currentDiscussionLastCommentId
|
||||
&& this.$el.querySelector(`.comment--${this.currentDiscussionLastCommentId}`),
|
||||
this.$el.querySelector('.comment--new'),
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
tops[discussionId] = getTop(
|
||||
discussion,
|
||||
this.$el.querySelector(`.comment--discussion-${discussionId}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
this.tops = tops;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$watch(
|
||||
() => this.updateTopsTrigger,
|
||||
() => this.updateTops(),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const layoutSettings = store.getters['data/layoutSettings'];
|
||||
this.scrollerElt = layoutSettings.showEditor
|
||||
? editorSvc.editorElt.parentNode
|
||||
: editorSvc.previewElt.parentNode;
|
||||
|
||||
this.updateSticky = () => {
|
||||
let height = 0;
|
||||
let offsetTop = this.tops.current;
|
||||
const lastCommentElt = this.$el.querySelector(`.comment--${this.currentDiscussionLastCommentId}`);
|
||||
if (lastCommentElt) {
|
||||
height += lastCommentElt.clientHeight;
|
||||
offsetTop += lastCommentElt.offsetTop;
|
||||
}
|
||||
const newCommentElt = this.$el.querySelector('.comment--new');
|
||||
if (newCommentElt) {
|
||||
height += newCommentElt.clientHeight;
|
||||
}
|
||||
const currentDiscussionElt = document.querySelector('.current-discussion__inner');
|
||||
const minOffsetTop = this.scrollerElt.scrollTop + 10;
|
||||
const maxOffsetTop = (this.scrollerElt.scrollTop + this.scrollerElt.clientHeight) - height
|
||||
- currentDiscussionElt.clientHeight;
|
||||
let stickyComment = null;
|
||||
if (offsetTop > maxOffsetTop || maxOffsetTop < minOffsetTop) {
|
||||
stickyComment = 'bottom';
|
||||
} else if (offsetTop < minOffsetTop) {
|
||||
stickyComment = 'top';
|
||||
}
|
||||
if (store.state.discussion.stickyComment !== stickyComment) {
|
||||
store.commit('discussion/setStickyComment', stickyComment);
|
||||
}
|
||||
};
|
||||
|
||||
this.scrollerElt.addEventListener('scroll', this.updateSticky);
|
||||
this.$watch(
|
||||
() => this.updateStickyTrigger,
|
||||
() => this.updateSticky(),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Move preview discussions once previewCtxWithDiffs has been calculated
|
||||
if (!editorSvc.previewCtxWithDiffs) {
|
||||
editorSvc.$once('previewCtxWithDiffs', () => {
|
||||
this.updateTops();
|
||||
this.updateSticky();
|
||||
});
|
||||
}
|
||||
},
|
||||
destroyed() {
|
||||
this.scrollerElt.removeEventListener('scroll', this.updateSticky);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
.comment-list {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.comment--last,
|
||||
.comment-list__current-discussion {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
/* use div selector to avoid collision with Prism */
|
||||
div.comment {
|
||||
padding: 5px 10px 10px;
|
||||
}
|
||||
|
||||
.comment--last {
|
||||
opacity: 0.33;
|
||||
cursor: pointer;
|
||||
|
||||
* {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.comment--hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.comment__header {
|
||||
font-size: 0.75em;
|
||||
padding-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.comment__user-image {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: $border-radius-base;
|
||||
overflow: hidden;
|
||||
margin-right: 5px;
|
||||
|
||||
.comment:hover & {
|
||||
display: none;
|
||||
|
||||
.sticky-comment & {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.comment--new:hover &,
|
||||
.comment--last:hover & {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.comment__remove-button {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
padding: 1px;
|
||||
color: rgba(0, 0, 0, 0.33);
|
||||
margin-right: 5px;
|
||||
display: none;
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.comment:hover & {
|
||||
display: block;
|
||||
|
||||
.sticky-comment & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.comment--last:hover & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.comment__created {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.comment__buttons {
|
||||
padding: 10px 5px 0;
|
||||
}
|
||||
|
||||
.comment__button {
|
||||
padding: 0 8px;
|
||||
line-height: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.comment__text {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
right: 0;
|
||||
border-top: 8px solid $editor-background-light;
|
||||
border-left: 8px solid transparent;
|
||||
|
||||
.app--dark & {
|
||||
border-top-color: $editor-background-dark;
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
blockquote,
|
||||
pre,
|
||||
ul,
|
||||
ol,
|
||||
dl {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-variant-ligatures: no-common-ligatures;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
caret-color: #000;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.comment__text-inner {
|
||||
min-height: 37px;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
padding: 1px 8px;
|
||||
background-color: $editor-background-light;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $border-radius-base;
|
||||
border-bottom-right-radius: 0;
|
||||
|
||||
.app--dark & {
|
||||
background-color: $editor-background-dark;
|
||||
}
|
||||
|
||||
.markdown-highlighting {
|
||||
padding: 5px 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
177
src/components/gutters/CurrentDiscussion.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="current-discussion" :style="{width: constants.gutterWidth + 'px'}">
|
||||
<sticky-comment v-if="stickyComment === 'bottom'"></sticky-comment>
|
||||
<div class="current-discussion__inner">
|
||||
<div class="flex flex--row flex--space-between">
|
||||
<div class="current-discussion__buttons flex flex--row flex--end">
|
||||
<button class="current-discussion__button button" v-if="showNext" @click="goToDiscussion(previousDiscussionId)" v-title="'上一个批注'">
|
||||
<icon-arrow-left></icon-arrow-left>
|
||||
</button>
|
||||
<button class="current-discussion__button current-discussion__button--rotate button" v-if="showNext" @click="goToDiscussion(nextDiscussionId)" v-title="'下一个批注'">
|
||||
<icon-arrow-left></icon-arrow-left>
|
||||
</button>
|
||||
</div>
|
||||
<div class="current-discussion__buttons flex flex--row flex--end">
|
||||
<button class="current-discussion__button current-discussion__button--remove button" v-if="showRemove" @click="removeDiscussion" v-title="'删除批注'">
|
||||
<icon-delete></icon-delete>
|
||||
</button>
|
||||
<button class="current-discussion__button button" @click="setCurrentDiscussionId()" v-title="'关闭批注'">
|
||||
<icon-close></icon-close>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="current-discussion__text markdown-highlighting markdown-highlighting--inline">
|
||||
<span @click="goToDiscussion()" v-html="text"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex';
|
||||
import editorSvc from '../../services/editorSvc';
|
||||
import animationSvc from '../../services/animationSvc';
|
||||
import markdownConversionSvc from '../../services/markdownConversionSvc';
|
||||
import StickyComment from './StickyComment';
|
||||
import store from '../../store';
|
||||
import badgeSvc from '../../services/badgeSvc';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
StickyComment,
|
||||
},
|
||||
computed: {
|
||||
...mapState('discussion', [
|
||||
'stickyComment',
|
||||
'currentDiscussionId',
|
||||
]),
|
||||
...mapGetters('discussion', [
|
||||
'currentDiscussion',
|
||||
'previousDiscussionId',
|
||||
'nextDiscussionId',
|
||||
'currentFileDiscussions',
|
||||
'currentDiscussionLastCommentId',
|
||||
]),
|
||||
...mapGetters('layout', [
|
||||
'constants',
|
||||
]),
|
||||
text() {
|
||||
return markdownConversionSvc.highlight(this.currentDiscussion.text);
|
||||
},
|
||||
showNext() {
|
||||
return this.nextDiscussionId && this.nextDiscussionId !== this.currentDiscussionId;
|
||||
},
|
||||
showRemove() {
|
||||
return this.currentDiscussionLastCommentId;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapMutations('discussion', [
|
||||
'setCurrentDiscussionId',
|
||||
]),
|
||||
...mapActions('notification', [
|
||||
'info',
|
||||
]),
|
||||
goToDiscussion(discussionId = this.currentDiscussionId) {
|
||||
this.setCurrentDiscussionId(discussionId);
|
||||
const layoutSettings = store.getters['data/layoutSettings'];
|
||||
const discussion = this.currentFileDiscussions[discussionId];
|
||||
const coordinates = layoutSettings.showEditor
|
||||
? editorSvc.clEditor.selectionMgr.getCoordinates(discussion.end)
|
||||
: editorSvc.getPreviewOffsetCoordinates(editorSvc.getPreviewOffset(discussion.end));
|
||||
if (!coordinates) {
|
||||
this.info("Discussion can't be located in the file.");
|
||||
} else {
|
||||
const scrollerElt = layoutSettings.showEditor
|
||||
? editorSvc.editorElt.parentNode
|
||||
: editorSvc.previewElt.parentNode;
|
||||
let scrollTop = coordinates.top - (scrollerElt.offsetHeight / 2);
|
||||
const maxScrollTop = scrollerElt.scrollHeight - scrollerElt.offsetHeight;
|
||||
if (scrollTop < 0) {
|
||||
scrollTop = 0;
|
||||
} else if (scrollTop > maxScrollTop) {
|
||||
scrollTop = maxScrollTop;
|
||||
}
|
||||
animationSvc.animate(scrollerElt)
|
||||
.scrollTop(scrollTop)
|
||||
.duration(200)
|
||||
.start();
|
||||
}
|
||||
},
|
||||
async removeDiscussion() {
|
||||
try {
|
||||
await store.dispatch('modal/open', 'discussionDeletion');
|
||||
store.dispatch('discussion/cleanCurrentFile', {
|
||||
filterDiscussion: this.currentDiscussion,
|
||||
});
|
||||
badgeSvc.addBadge('removeDiscussion');
|
||||
} catch (e) {
|
||||
// Cancel
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
.current-discussion {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
.sticky-comment {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.current-discussion__inner {
|
||||
position: relative;
|
||||
font-size: 16px;
|
||||
background-color: $info-bg;
|
||||
max-height: 130px; /* 3 lines max */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.current-discussion__buttons {
|
||||
padding: 4px 4px 0;
|
||||
}
|
||||
|
||||
.current-discussion__button {
|
||||
width: 30px;
|
||||
height: 28px;
|
||||
padding: 2px;
|
||||
flex: none;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
.current-discussion__button--remove {
|
||||
/* Make the trash a bit smaller */
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.current-discussion__button--rotate {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.current-discussion__text {
|
||||
padding: 10px;
|
||||
|
||||
span {
|
||||
padding: 0.2em 0;
|
||||
background-color: mix($editor-background-light, $selection-highlighting-color, 10%);
|
||||
cursor: pointer;
|
||||
|
||||
.app--dark {
|
||||
background-color: mix($editor-background-dark, $selection-highlighting-color, 10%);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
55
src/components/gutters/EditorNewDiscussionButton.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<a class="new-discussion-button" href="javascript:void(0)" v-if="coordinates" :style="{top: coordinates.top + 'px'}" v-title="'开始批注'" @mousedown.stop.prevent @click="createNewDiscussion(selection)">
|
||||
<icon-message></icon-message>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions } from 'vuex';
|
||||
import editorSvc from '../../services/editorSvc';
|
||||
import store from '../../store';
|
||||
|
||||
export default {
|
||||
data: () => ({
|
||||
selection: null,
|
||||
coordinates: null,
|
||||
}),
|
||||
methods: {
|
||||
...mapActions('discussion', [
|
||||
'createNewDiscussion',
|
||||
]),
|
||||
checkSelection() {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = setTimeout(() => {
|
||||
let offset;
|
||||
// Show the button if content is not a revision and has the focus
|
||||
if (
|
||||
!store.state.content.revisionContent &&
|
||||
editorSvc.clEditor.selectionMgr.hasFocus()
|
||||
) {
|
||||
this.selection = editorSvc.getTrimmedSelection();
|
||||
if (this.selection) {
|
||||
const text = editorSvc.clEditor.getContent();
|
||||
offset = this.selection.end;
|
||||
while (offset && text[offset - 1] === '\n') {
|
||||
offset -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.coordinates = offset
|
||||
? editorSvc.clEditor.selectionMgr.getCoordinates(offset)
|
||||
: null;
|
||||
}, 25);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
editorSvc.clEditor.selectionMgr.on('selectionChanged', () => this.checkSelection());
|
||||
editorSvc.clEditor.selectionMgr.on('cursorCoordinatesChanged', () => this.checkSelection());
|
||||
editorSvc.clEditor.on('focus', () => this.checkSelection());
|
||||
editorSvc.clEditor.on('blur', () => this.checkSelection());
|
||||
this.checkSelection();
|
||||
});
|
||||
},
|
||||
};
|
||||
</script>
|
||||
168
src/components/gutters/NewComment.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div class="comment comment--new" @keydown.esc.stop="cancelNewComment">
|
||||
<div class="comment__header flex flex--row flex--space-between flex--align-center">
|
||||
<div class="comment__user flex flex--row flex--align-center">
|
||||
<div class="comment__user-image">
|
||||
<user-image :user-id="userId"></user-image>
|
||||
</div>
|
||||
<span class="user-name">{{loginToken.name}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment__text">
|
||||
<div class="comment__text-inner">
|
||||
<pre class="markdown-highlighting"></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment__buttons flex flex--row flex--end">
|
||||
<button class="comment__button button" @click="cancelNewComment">取消</button>
|
||||
<button class="comment__button button" @click="addComment">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters, mapMutations, mapActions } from 'vuex';
|
||||
import Prism from 'prismjs';
|
||||
import UserImage from '../UserImage';
|
||||
import cledit from '../../services/editor/cledit';
|
||||
import editorSvc from '../../services/editorSvc';
|
||||
import markdownConversionSvc from '../../services/markdownConversionSvc';
|
||||
import utils from '../../services/utils';
|
||||
import userSvc from '../../services/userSvc';
|
||||
import store from '../../store';
|
||||
import badgeSvc from '../../services/badgeSvc';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
UserImage,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('workspace', [
|
||||
'loginToken',
|
||||
]),
|
||||
userId() {
|
||||
return userSvc.getCurrentUserId();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapMutations('discussion', [
|
||||
'setNewCommentFocus',
|
||||
]),
|
||||
...mapActions('discussion', [
|
||||
'cancelNewComment',
|
||||
]),
|
||||
addComment() {
|
||||
const text = store.state.discussion.newCommentText.trim();
|
||||
if (text.length) {
|
||||
if (text.length > 2000) {
|
||||
store.dispatch('notification/error', 'Comment is too long.');
|
||||
} else {
|
||||
// Create comment
|
||||
const discussionId = store.state.discussion.currentDiscussionId;
|
||||
const comment = {
|
||||
discussionId,
|
||||
sub: this.userId,
|
||||
text,
|
||||
created: Date.now(),
|
||||
};
|
||||
const patch = {
|
||||
comments: {
|
||||
...store.getters['content/current'].comments,
|
||||
[utils.uid()]: comment,
|
||||
},
|
||||
};
|
||||
if (discussionId === store.state.discussion.newDiscussionId) {
|
||||
// Create discussion
|
||||
patch.discussions = {
|
||||
...store.getters['content/current'].discussions,
|
||||
[discussionId]: store.getters['discussion/newDiscussion'],
|
||||
};
|
||||
badgeSvc.addBadge('createDiscussion');
|
||||
} else {
|
||||
badgeSvc.addBadge('addComment');
|
||||
}
|
||||
store.dispatch('content/patchCurrent', patch);
|
||||
store.commit('discussion/setNewCommentText');
|
||||
store.commit('discussion/setIsCommenting');
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const preElt = this.$el.querySelector('pre.markdown-highlighting');
|
||||
const scrollerElt = this.$el.querySelector('.comment__text-inner');
|
||||
const clEditor = cledit(preElt, scrollerElt, true);
|
||||
clEditor.init({
|
||||
sectionHighlighter: section => Prism.highlight(
|
||||
section.text,
|
||||
editorSvc.prismGrammars[section.data],
|
||||
),
|
||||
sectionParser: text => markdownConversionSvc
|
||||
.parseSections(editorSvc.converter, text).sections,
|
||||
content: store.state.discussion.newCommentText,
|
||||
selectionStart: store.state.discussion.newCommentSelection.start,
|
||||
selectionEnd: store.state.discussion.newCommentSelection.end,
|
||||
getCursorFocusRatio: () => 0.2,
|
||||
});
|
||||
clEditor.on('focus', () => this.setNewCommentFocus(true));
|
||||
|
||||
// Save typed content and selection
|
||||
clEditor.on('contentChanged', value =>
|
||||
store.commit('discussion/setNewCommentText', value));
|
||||
clEditor.selectionMgr.on('selectionChanged', (start, end) =>
|
||||
store.commit('discussion/setNewCommentSelection', {
|
||||
start, end,
|
||||
}));
|
||||
|
||||
const isSticky = this.$el.parentNode.classList.contains('sticky-comment');
|
||||
const isVisible = () => isSticky || store.state.discussion.stickyComment === null;
|
||||
|
||||
this.$watch(
|
||||
() => store.state.discussion.currentDiscussionId,
|
||||
() => this.$nextTick(() => {
|
||||
if (isVisible() && store.state.discussion.newCommentFocus) {
|
||||
clEditor.focus();
|
||||
}
|
||||
}),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
if (isSticky) {
|
||||
let scrollerMirrorElt;
|
||||
const getScrollerMirrorElt = () => {
|
||||
if (!scrollerMirrorElt) {
|
||||
scrollerMirrorElt = document.querySelector('.comment-list .comment--new .comment__text-inner');
|
||||
}
|
||||
return scrollerMirrorElt || { scrollTop: 0 };
|
||||
};
|
||||
|
||||
scrollerElt.scrollTop = getScrollerMirrorElt().scrollTop;
|
||||
scrollerElt.addEventListener('scroll', () => {
|
||||
getScrollerMirrorElt().scrollTop = scrollerElt.scrollTop;
|
||||
});
|
||||
} else {
|
||||
// Maintain the state with the sticky comment
|
||||
this.$watch(
|
||||
() => isVisible(),
|
||||
(visible) => {
|
||||
clEditor.toggleEditable(visible);
|
||||
if (visible) {
|
||||
const text = store.state.discussion.newCommentText;
|
||||
clEditor.setContent(text);
|
||||
const selection = store.state.discussion.newCommentSelection;
|
||||
clEditor.selectionMgr.setSelectionStartEnd(selection.start, selection.end);
|
||||
if (store.state.discussion.newCommentFocus) {
|
||||
clEditor.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
this.$watch(
|
||||
() => store.state.discussion.newCommentText,
|
||||
newCommentText => clEditor.setContent(newCommentText),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
56
src/components/gutters/PreviewNewDiscussionButton.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<a class="new-discussion-button" href="javascript:void(0)" v-if="coordinates" :style="{top: coordinates.top + 'px'}" v-title="'添加批注'" @mousedown.stop.prevent @click="createNewDiscussion(selection)">
|
||||
<icon-message></icon-message>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions } from 'vuex';
|
||||
import editorSvc from '../../services/editorSvc';
|
||||
import store from '../../store';
|
||||
|
||||
export default {
|
||||
data: () => ({
|
||||
selection: null,
|
||||
coordinates: null,
|
||||
}),
|
||||
methods: {
|
||||
...mapActions('discussion', [
|
||||
'createNewDiscussion',
|
||||
]),
|
||||
checkSelection() {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = setTimeout(() => {
|
||||
let offset;
|
||||
// Show the button if content is not a revision and preview selection is not empty
|
||||
if (
|
||||
!store.state.content.revisionContent &&
|
||||
editorSvc.previewSelectionRange
|
||||
) {
|
||||
this.selection = editorSvc.getTrimmedSelection();
|
||||
if (this.selection) {
|
||||
const { text } = editorSvc.previewCtxWithDiffs;
|
||||
offset = editorSvc.getPreviewOffset(this.selection.end);
|
||||
while (offset && text[offset - 1] === '\n') {
|
||||
offset -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.coordinates = offset
|
||||
? editorSvc.getPreviewOffsetCoordinates(offset)
|
||||
: null;
|
||||
}, 25);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
editorSvc.$on('previewSelectionRange', () => this.checkSelection());
|
||||
this.$watch(
|
||||
() => store.getters['layout/styles'].previewWidth,
|
||||
() => this.checkSelection(),
|
||||
);
|
||||
this.checkSelection();
|
||||
});
|
||||
},
|
||||
};
|
||||
</script>
|
||||
48
src/components/gutters/StickyComment.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="sticky-comment" :style="{width: constants.gutterWidth + 'px', top: top + 'px'}">
|
||||
<comment v-if="currentDiscussionLastComment" :comment="currentDiscussionLastComment"></comment>
|
||||
<new-comment v-if="isCommenting"></new-comment>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapGetters } from 'vuex';
|
||||
import Comment from './Comment';
|
||||
import NewComment from './NewComment';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Comment,
|
||||
NewComment,
|
||||
},
|
||||
data: () => ({
|
||||
top: 0,
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters('layout', [
|
||||
'constants',
|
||||
]),
|
||||
...mapState('discussion', [
|
||||
'isCommenting',
|
||||
]),
|
||||
...mapGetters('discussion', [
|
||||
'currentDiscussionLastComment',
|
||||
]),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
.sticky-comment {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
font-size: 15px;
|
||||
padding-top: 10px;
|
||||
|
||||
.current-discussion & {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
116
src/components/menus/EditThemeMenu.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="edit-theme side-bar__panel side-bar__panel--menu">
|
||||
<div class="side-bar__info">
|
||||
<div class="menu-entry menu-entry--info flex flex--row flex--align-center">
|
||||
<span v-if="currEditTheme==='custom'">
|
||||
下面的自定义主题样式可编辑,可参考其他主题样式填入自己喜欢的编辑样式。<br>
|
||||
主题class为:edit-theme--custom
|
||||
</span>
|
||||
<span v-else>
|
||||
下面的主题样式不可编辑。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-bar__content">
|
||||
<template v-if="currEditTheme === 'default'">
|
||||
默认主题无额外样式,请选择其他主题。
|
||||
</template>
|
||||
<template v-else>
|
||||
<code-editor v-for="(value, index) in styleEles" :key="index"
|
||||
v-if="value.id === `edit-theme-${currEditTheme}`" lang="css" :value="value.innerHTML"
|
||||
:disabled="value.id!=='edit-theme-custom'" @changed="changeText" scrollClass="side-bar__inner"></code-editor>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex flex--row flex--end" v-if="currEditTheme==='custom'">
|
||||
<button class="edit-theme__button button" @click="saveStyleText">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import MenuEntry from './common/MenuEntry';
|
||||
import CodeEditor from '../CodeEditor';
|
||||
import store from '../../store';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MenuEntry,
|
||||
CodeEditor,
|
||||
},
|
||||
data: () => ({
|
||||
themeStyleText: '',
|
||||
styleEles: [],
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters('theme', [
|
||||
'currEditTheme',
|
||||
]),
|
||||
},
|
||||
methods: {
|
||||
saveStyleText() {
|
||||
const typeEle = this.findByTheme(this.currEditTheme);
|
||||
if (!typeEle || !this.themeStyleText) {
|
||||
return;
|
||||
}
|
||||
typeEle.innerHTML = this.themeStyleText;
|
||||
store.dispatch('theme/setCustomEditThemeStyle', this.themeStyleText);
|
||||
store.dispatch('notification/info', '保存自定义主题样式成功!');
|
||||
},
|
||||
findByTheme(theme) {
|
||||
const findEles = this.styleEles.filter(it => it.id === `edit-theme-${theme}`);
|
||||
return findEles.length ? findEles[0] : null;
|
||||
},
|
||||
changeText(text) {
|
||||
this.themeStyleText = text;
|
||||
},
|
||||
close() {
|
||||
store.dispatch('data/setSideBarPanel', 'menu');
|
||||
},
|
||||
initStyle(theme) {
|
||||
if (theme === 'default') {
|
||||
return;
|
||||
}
|
||||
const value = theme || this.currEditTheme;
|
||||
if (this.findByTheme(value)) {
|
||||
return;
|
||||
}
|
||||
const styleId = `edit-theme-${value}`;
|
||||
const styleEle = document.getElementById(styleId);
|
||||
if (!styleEle) {
|
||||
setTimeout(() => this.initStyle(value), 1000);
|
||||
return;
|
||||
}
|
||||
this.styleEles.push(styleEle);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currEditTheme: {
|
||||
immediate: true,
|
||||
handler(val) {
|
||||
this.initStyle(val);
|
||||
},
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.initStyle();
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.side-bar__panel--menu {
|
||||
.side-bar__content {
|
||||
.code-editor {
|
||||
min-height: 400px !important;
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-theme__button {
|
||||
font-size: 14px;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
474
src/components/menus/HistoryMenu.vue
Normal file
@@ -0,0 +1,474 @@
|
||||
<template>
|
||||
<div class="history side-bar__panel side-bar__panel--menu">
|
||||
<div class="side-bar__info">
|
||||
<p v-if="syncLocations.length > 1">
|
||||
<select slot="field" class="textfield" v-model="syncLocationId" @keydown.enter="resolve()">
|
||||
<option v-for="location in syncLocations" :key="location.id" :value="location.id">
|
||||
{{ location.description }}
|
||||
</option>
|
||||
</select>
|
||||
</p>
|
||||
<p v-if="!historyContext">同步 <b>{{currentFileName}}</b> 以启用修订历史 或者 <a href="javascript:void(0)" @click="signin">登录 Gitee</a> 或 <a href="javascript:void(0)" @click="signinWithGithub">登录 GitHub</a> 以同步您的主文档空间。</p>
|
||||
<p v-else-if="loading">历史版本加载中…</p>
|
||||
<p v-else-if="!revisionsWithSpacer.length"><b>{{currentFileName}}</b> 没有历史版本.</p>
|
||||
<div class="menu-entry menu-entry--info flex flex--row flex--align-center" v-else>
|
||||
<div class="menu-entry__icon menu-entry__icon--image">
|
||||
<icon-provider :provider-id="syncLocation.providerId"></icon-provider>
|
||||
</div>
|
||||
<span v-if="syncLocation.url">
|
||||
下面的历史版本存储在 <a :href="syncLocation.url" target="_blank">{{ syncLocationProviderName }}</a>.
|
||||
</span>
|
||||
<span v-else>
|
||||
下面的历史版本存储在 {{ syncLocationProviderName }}.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="revision" v-for="revision in revisionsWithSpacer" :key="revision.id">
|
||||
<div class="history__spacer" v-if="revision.spacer"></div>
|
||||
<a class="revision__button button flex flex--row" href="javascript:void(0)" @click="open(revision)">
|
||||
<div class="revision__icon">
|
||||
<user-image :user-id="revision.sub"></user-image>
|
||||
</div>
|
||||
<div class="revision__header flex flex--column">
|
||||
<user-name :user-id="revision.sub"></user-name>
|
||||
<div class="revision__created">{{revision.created | formatTime}}</div>
|
||||
<div class="revision__msg">{{revision.message}}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history__spacer history__spacer--last" v-if="revisions.length"></div>
|
||||
<div class="flex flex--row flex--end" v-if="showMoreButton">
|
||||
<button class="history__button button" @click="showMore">更多</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapMutations, mapGetters } from 'vuex';
|
||||
import providerRegistry from '../../services/providers/common/providerRegistry';
|
||||
import MenuEntry from './common/MenuEntry';
|
||||
import UserImage from '../UserImage';
|
||||
import UserName from '../UserName';
|
||||
import EditorClassApplier from '../common/EditorClassApplier';
|
||||
import PreviewClassApplier from '../common/PreviewClassApplier';
|
||||
import utils from '../../services/utils';
|
||||
import giteeHelper from '../../services/providers/helpers/giteeHelper';
|
||||
import githubHelper from '../../services/providers/helpers/githubHelper';
|
||||
import syncSvc from '../../services/syncSvc';
|
||||
import store from '../../store';
|
||||
import badgeSvc from '../../services/badgeSvc';
|
||||
|
||||
let editorClassAppliers = [];
|
||||
let previewClassAppliers = [];
|
||||
|
||||
let cachedHistoryContextHash;
|
||||
let revisionsPromise;
|
||||
let revisionContentPromises;
|
||||
const pageSize = 30;
|
||||
const spacerThreshold = 6 * 60 * 60 * 1000; // 6h
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MenuEntry,
|
||||
UserImage,
|
||||
UserName,
|
||||
},
|
||||
data: () => ({
|
||||
allRevisions: [],
|
||||
loading: false,
|
||||
showCount: pageSize,
|
||||
syncLocationId: null,
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters('data', [
|
||||
'syncDataByItemId',
|
||||
]),
|
||||
...mapGetters('syncLocation', {
|
||||
syncLocations: 'currentWithWorkspaceSyncLocation',
|
||||
}),
|
||||
...mapState('content', [
|
||||
'revisionContent',
|
||||
]),
|
||||
syncLocation() {
|
||||
return utils.someResult(this.syncLocations, (syncLocation) => {
|
||||
if (syncLocation.id === this.syncLocationId) {
|
||||
return syncLocation;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
},
|
||||
syncLocationProviderName() {
|
||||
if (!this.syncLocation) {
|
||||
return null;
|
||||
}
|
||||
return providerRegistry.providersById[this.syncLocation.providerId].name;
|
||||
},
|
||||
currentFileName() {
|
||||
return store.getters['file/current'].name;
|
||||
},
|
||||
historyContext() {
|
||||
const { syncLocation } = this;
|
||||
if (syncLocation) {
|
||||
const provider = providerRegistry.providersById[syncLocation.providerId];
|
||||
const token = provider.getToken(syncLocation);
|
||||
const fileId = store.getters['file/current'].id;
|
||||
const contentId = `${fileId}/content`;
|
||||
const historyContext = {
|
||||
token,
|
||||
fileId,
|
||||
contentId,
|
||||
syncLocation: this.syncLocation,
|
||||
};
|
||||
if (syncLocation.id !== 'main') {
|
||||
return historyContext;
|
||||
}
|
||||
|
||||
// Add syncData for workspace sync location
|
||||
const { syncDataByItemId } = this;
|
||||
const fileSyncData = syncDataByItemId[fileId];
|
||||
const contentSyncData = syncDataByItemId[contentId];
|
||||
if (fileSyncData && contentSyncData) {
|
||||
return {
|
||||
...historyContext,
|
||||
fileSyncDataId: fileSyncData.id,
|
||||
contentSyncDataId: contentSyncData.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
historyContextHash() {
|
||||
return utils.serializeObject(this.historyContext);
|
||||
},
|
||||
revisions() {
|
||||
return this.allRevisions.slice()
|
||||
.sort((revision1, revision2) => revision2.created - revision1.created)
|
||||
.slice(0, this.showCount);
|
||||
},
|
||||
revisionsWithSpacer() {
|
||||
let previousCreated = 0;
|
||||
return this.revisions.map((revision) => {
|
||||
const revisionWithSpacer = {
|
||||
...revision,
|
||||
spacer: revision.created + spacerThreshold < previousCreated,
|
||||
};
|
||||
previousCreated = revision.created;
|
||||
return revisionWithSpacer;
|
||||
});
|
||||
},
|
||||
showMoreButton() {
|
||||
return this.showCount < this.allRevisions.length;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapMutations('content', [
|
||||
'setRevisionContent',
|
||||
]),
|
||||
async signin() {
|
||||
try {
|
||||
await giteeHelper.signin();
|
||||
await syncSvc.afterSignIn();
|
||||
syncSvc.requestSync();
|
||||
} catch (e) {
|
||||
// Cancel
|
||||
}
|
||||
},
|
||||
async signinWithGithub() {
|
||||
try {
|
||||
await githubHelper.signin();
|
||||
await syncSvc.afterSignIn();
|
||||
syncSvc.requestSync();
|
||||
} catch (e) {
|
||||
// Cancel
|
||||
}
|
||||
},
|
||||
close() {
|
||||
store.dispatch('data/setSideBarPanel', 'menu');
|
||||
},
|
||||
showMore() {
|
||||
this.showCount += pageSize;
|
||||
},
|
||||
open(revision) {
|
||||
let revisionContentPromise = revisionContentPromises[revision.id];
|
||||
if (!revisionContentPromise) {
|
||||
const historyContext = utils.deepCopy(this.historyContext);
|
||||
if (historyContext) {
|
||||
const provider = providerRegistry.providersById[this.syncLocation.providerId];
|
||||
revisionContentPromise = new Promise((resolve, reject) => store.dispatch(
|
||||
'queue/enqueue',
|
||||
() => provider.getFileRevisionContent({
|
||||
...historyContext,
|
||||
revisionId: revision.id,
|
||||
})
|
||||
.then(resolve, reject),
|
||||
));
|
||||
revisionContentPromises[revision.id] = revisionContentPromise;
|
||||
revisionContentPromise.catch((err) => {
|
||||
store.dispatch('notification/error', err);
|
||||
revisionContentPromises[revision.id] = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
if (revisionContentPromise) {
|
||||
revisionContentPromise.then(revisionContent =>
|
||||
store.dispatch('content/setRevisionContent', revisionContent));
|
||||
}
|
||||
},
|
||||
refreshHighlighters() {
|
||||
const { revisionContent } = this;
|
||||
editorClassAppliers.forEach(editorClassApplier => editorClassApplier.stop());
|
||||
editorClassAppliers = [];
|
||||
previewClassAppliers.forEach(previewClassApplier => previewClassApplier.stop());
|
||||
previewClassAppliers = [];
|
||||
if (revisionContent) {
|
||||
let offset = 0;
|
||||
revisionContent.diffs.forEach(([type, text]) => {
|
||||
if (type) {
|
||||
const classes = ['revision-diff', `revision-diff--${type > 0 ? 'insert' : 'delete'}`];
|
||||
const offsets = {
|
||||
start: offset,
|
||||
end: offset + text.length,
|
||||
};
|
||||
editorClassAppliers.push(new EditorClassApplier(
|
||||
[`revision-diff--${utils.uid()}`, ...classes],
|
||||
offsets,
|
||||
));
|
||||
previewClassAppliers.push(new PreviewClassApplier(
|
||||
[`revision-diff--${utils.uid()}`, ...classes],
|
||||
offsets,
|
||||
));
|
||||
}
|
||||
offset += text.length;
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
// Fix syncLocationId
|
||||
syncLocation: {
|
||||
immediate: true,
|
||||
handler(value) {
|
||||
const firstSyncLocation = this.syncLocations[0];
|
||||
if (firstSyncLocation) {
|
||||
if (!value) {
|
||||
this.syncLocationId = firstSyncLocation.id;
|
||||
} else if (value.id !== firstSyncLocation.id) {
|
||||
badgeSvc.addBadge('chooseHistory');
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
// Load revision list on context changes
|
||||
historyContextHash: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.allRevisions = [];
|
||||
const historyContext = utils.deepCopy(this.historyContext);
|
||||
if (historyContext) {
|
||||
if (this.historyContextHash !== cachedHistoryContextHash) {
|
||||
this.setRevisionContent();
|
||||
cachedHistoryContextHash = this.historyContextHash;
|
||||
revisionContentPromises = {};
|
||||
const provider = providerRegistry.providersById[this.syncLocation.providerId];
|
||||
revisionsPromise = new Promise((resolve, reject) => store.dispatch(
|
||||
'queue/enqueue',
|
||||
() => provider
|
||||
.listFileRevisions(historyContext)
|
||||
.then(resolve, reject),
|
||||
))
|
||||
.catch((err) => {
|
||||
store.dispatch('notification/error', err);
|
||||
cachedHistoryContextHash = null;
|
||||
return [];
|
||||
});
|
||||
}
|
||||
if (revisionsPromise) {
|
||||
this.loading = true;
|
||||
revisionsPromise.then((revisions) => {
|
||||
this.loading = false;
|
||||
this.allRevisions = revisions;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
// Load each revision on revision list changes
|
||||
revisions(revisions) {
|
||||
const { historyContext } = this;
|
||||
if (historyContext) {
|
||||
store.dispatch(
|
||||
'queue/enqueue',
|
||||
() => utils.awaitSequence(revisions, async (revision) => {
|
||||
// Make sure revisions and historyContext haven't changed
|
||||
if (!this.destroyed
|
||||
&& this.revisions === revisions
|
||||
&& this.historyContext === historyContext
|
||||
) {
|
||||
const provider = providerRegistry.providersById[this.syncLocation.providerId];
|
||||
await provider.loadFileRevision({
|
||||
...historyContext,
|
||||
revision,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
// Refresh highlighters on open/close revision
|
||||
revisionContent: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.refreshHighlighters();
|
||||
},
|
||||
},
|
||||
},
|
||||
created() {
|
||||
// Close revision on escape
|
||||
this.onKeyup = (evt) => {
|
||||
if (evt.which === 27) {
|
||||
// Esc key
|
||||
this.setRevisionContent();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keyup', this.onKeyup);
|
||||
},
|
||||
destroyed() {
|
||||
// Close revision
|
||||
this.setRevisionContent();
|
||||
// Remove highlighters
|
||||
this.refreshHighlighters();
|
||||
// Remove event listener
|
||||
window.removeEventListener('keyup', this.onKeyup);
|
||||
// Cancel loading revisions
|
||||
this.destroyed = true;
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
.history__button {
|
||||
font-size: 14px;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
.history__spacer {
|
||||
position: relative;
|
||||
height: 40px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 19px;
|
||||
border-left: 2px dotted $hr-color;
|
||||
}
|
||||
}
|
||||
|
||||
.history__spacer--last {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.revision__button {
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
height: auto;
|
||||
text-transform: none;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 19px;
|
||||
border-left: 2px solid $hr-color;
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.revision:first-child &::before {
|
||||
height: 67%;
|
||||
top: 33%;
|
||||
}
|
||||
}
|
||||
|
||||
.revision__icon {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
margin-right: 12px;
|
||||
flex: none;
|
||||
border-radius: $border-radius-base;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.revision__header {
|
||||
font-size: 15px;
|
||||
width: 100%;
|
||||
line-height: 1.33;
|
||||
}
|
||||
|
||||
.revision__created {
|
||||
font-size: 0.75em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.revision__msg {
|
||||
font-size: 0.75em;
|
||||
opacity: 0.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.layout--revision {
|
||||
.cledit-section *,
|
||||
.cl-preview-section * {
|
||||
color: transparentize($editor-color-light, 0.5) !important;
|
||||
|
||||
.app--dark & {
|
||||
color: transparentize($editor-color-dark, 0.5) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cledit-section .revision-diff {
|
||||
color: $editor-color-light !important;
|
||||
|
||||
.app--dark & {
|
||||
color: $editor-color-dark !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cl-preview-section .revision-diff {
|
||||
color: $body-color-light !important;
|
||||
|
||||
.app--dark & {
|
||||
color: $body-color-dark !important;
|
||||
}
|
||||
}
|
||||
|
||||
.revision-diff {
|
||||
padding: 0.25em 0;
|
||||
|
||||
&.revision-diff--insert {
|
||||
background-color: mix(#fff, $selection-highlighting-color, 60%);
|
||||
}
|
||||
|
||||
&.revision-diff--delete {
|
||||
background-color: mix(#fff, $error-color, 60%);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
127
src/components/menus/ImportExportMenu.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="side-bar__panel side-bar__panel--menu">
|
||||
<input class="hidden-file" id="import-markdown-file-input" type="file" @change="onImportMarkdown">
|
||||
<label class="menu-entry button flex flex--row flex--align-center" for="import-markdown-file-input">
|
||||
<div class="menu-entry__icon flex flex--column flex--center">
|
||||
<icon-upload></icon-upload>
|
||||
</div>
|
||||
<div class="flex flex--column">
|
||||
<div>导入 Markdown</div>
|
||||
<span>导入纯文本文件。</span>
|
||||
</div>
|
||||
</label>
|
||||
<input class="hidden-file" id="import-html-file-input" type="file" @change="onImportHtml">
|
||||
<label class="menu-entry button flex flex--row flex--align-center" for="import-html-file-input">
|
||||
<div class="menu-entry__icon flex flex--column flex--center">
|
||||
<icon-upload></icon-upload>
|
||||
</div>
|
||||
<div class="flex flex--column">
|
||||
<div>导入 HTML</div>
|
||||
<span>将HTML文件转换为Markdown。</span>
|
||||
</div>
|
||||
</label>
|
||||
<hr>
|
||||
<menu-entry @click.native="exportMarkdown">
|
||||
<icon-download slot="icon"></icon-download>
|
||||
<div>导出为 Markdown</div>
|
||||
<span>保存纯文本文件。</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="exportHtml">
|
||||
<icon-download slot="icon"></icon-download>
|
||||
<div>导出为 HTML</div>
|
||||
<span>从模板生成HTML页面。</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="exportPdf">
|
||||
<icon-download slot="icon"></icon-download>
|
||||
<div>导出为 HTML PDF</div>
|
||||
<span>从HTML模板生成PDF。</span>
|
||||
</menu-entry>
|
||||
<!-- <menu-entry @click.native="exportPandoc">
|
||||
<icon-download slot="icon"></icon-download>
|
||||
<div>导出为 HTML Pandoc</div>
|
||||
<span>转换为PDF、Word、EPUB...</span>
|
||||
</menu-entry> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import TurndownService from 'turndown/lib/turndown.browser.umd';
|
||||
import htmlSanitizer from '../../libs/htmlSanitizer';
|
||||
import MenuEntry from './common/MenuEntry';
|
||||
import Provider from '../../services/providers/common/Provider';
|
||||
import store from '../../store';
|
||||
import workspaceSvc from '../../services/workspaceSvc';
|
||||
import exportSvc from '../../services/exportSvc';
|
||||
import badgeSvc from '../../services/badgeSvc';
|
||||
|
||||
const turndownService = new TurndownService(store.getters['data/computedSettings'].turndown);
|
||||
|
||||
const readFile = file => new Promise((resolve) => {
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target.result;
|
||||
if (content.match(/\uFFFD/)) {
|
||||
store.dispatch('notification/error', 'File is not readable.');
|
||||
} else {
|
||||
resolve(content);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
});
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MenuEntry,
|
||||
},
|
||||
computed: mapGetters(['isSponsor']),
|
||||
methods: {
|
||||
async onImportMarkdown(evt) {
|
||||
const file = evt.target.files[0];
|
||||
const content = await readFile(file);
|
||||
const item = await workspaceSvc.createFile({
|
||||
...Provider.parseContent(content),
|
||||
name: file.name,
|
||||
});
|
||||
store.commit('file/setCurrentId', item.id);
|
||||
badgeSvc.addBadge('importMarkdown');
|
||||
},
|
||||
async onImportHtml(evt) {
|
||||
const file = evt.target.files[0];
|
||||
const content = await readFile(file);
|
||||
const sanitizedContent = htmlSanitizer.sanitizeHtml(content)
|
||||
.replace(/ /g, ' '); // Replace non-breaking spaces with classic spaces
|
||||
const item = await workspaceSvc.createFile({
|
||||
...Provider.parseContent(turndownService.turndown(sanitizedContent)),
|
||||
name: file.name,
|
||||
});
|
||||
store.commit('file/setCurrentId', item.id);
|
||||
badgeSvc.addBadge('importHtml');
|
||||
},
|
||||
async exportMarkdown() {
|
||||
const currentFile = store.getters['file/current'];
|
||||
try {
|
||||
await exportSvc.exportToDisk(currentFile.id, 'md');
|
||||
badgeSvc.addBadge('exportMarkdown');
|
||||
} catch (e) { /* Cancel */ }
|
||||
},
|
||||
async exportHtml() {
|
||||
try {
|
||||
await store.dispatch('modal/open', 'htmlExport');
|
||||
} catch (e) { /* Cancel */ }
|
||||
},
|
||||
async exportPdf() {
|
||||
try {
|
||||
await store.dispatch('modal/open', 'pdfExport');
|
||||
} catch (e) { /* Cancel */ }
|
||||
},
|
||||
async exportPandoc() {
|
||||
try {
|
||||
await store.dispatch('modal/open', 'pandocExport');
|
||||
} catch (e) { /* Cancel */ }
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
263
src/components/menus/MainMenu.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<div class="side-bar__panel side-bar__panel--menu">
|
||||
<div class="side-bar__info">
|
||||
<div class="menu-entry menu-entry--info flex flex--row flex--align-center" v-if="loginToken">
|
||||
<div class="menu-entry__icon menu-entry__icon--image">
|
||||
<user-image :user-id="userId"></user-image>
|
||||
</div>
|
||||
<span>登录名为<b>{{loginToken.name}}</b>。</span>
|
||||
</div>
|
||||
<div class="menu-entry menu-entry--info flex flex--row flex--align-center" v-if="syncToken">
|
||||
<div class="menu-entry__icon menu-entry__icon--image">
|
||||
<icon-provider :provider-id="currentWorkspace.providerId"></icon-provider>
|
||||
</div>
|
||||
<span v-if="currentWorkspace.providerId === 'giteeAppData'">
|
||||
<b>{{currentWorkspace.name}}</b> 与您的 Gitee 默认文档空间仓库同步。
|
||||
</span>
|
||||
<span v-else-if="currentWorkspace.providerId === 'githubAppData'">
|
||||
<b>{{currentWorkspace.name}}</b> 与您的 GitHub 默认文档空间仓库同步。
|
||||
</span>
|
||||
<span v-else-if="currentWorkspace.providerId === 'googleDriveWorkspace'">
|
||||
<b>{{currentWorkspace.name}}</b> 与 <a :href="workspaceLocationUrl" target="_blank">Google Drive 文件夹</a>同步。
|
||||
</span>
|
||||
<span v-else-if="currentWorkspace.providerId === 'couchdbWorkspace'">
|
||||
<b>{{currentWorkspace.name}}</b> 与 <a :href="workspaceLocationUrl" target="_blank">CouchDB 数据库</a>同步。
|
||||
</span>
|
||||
<span v-else-if="currentWorkspace.providerId === 'githubWorkspace'">
|
||||
<b>{{currentWorkspace.name}}</b> 与 <a :href="workspaceLocationUrl" target="_blank">GitHub 仓库</a> 同步。
|
||||
</span>
|
||||
<span v-else-if="currentWorkspace.providerId === 'giteeWorkspace'">
|
||||
<b>{{currentWorkspace.name}}</b> 与 <a :href="workspaceLocationUrl" target="_blank">Gitee 仓库</a> 同步。
|
||||
</span>
|
||||
<span v-else-if="currentWorkspace.providerId === 'gitlabWorkspace'">
|
||||
<b>{{currentWorkspace.name}}</b> 与 <a :href="workspaceLocationUrl" target="_blank">GitLab 项目</a>同步。
|
||||
</span>
|
||||
<span v-else-if="currentWorkspace.providerId === 'giteaWorkspace'">
|
||||
<b>{{currentWorkspace.name}}</b> 与 <a :href="workspaceLocationUrl" target="_blank">Gitea 项目</a>同步。
|
||||
</span>
|
||||
</div>
|
||||
<div class="menu-entry menu-entry--info flex flex--row flex--align-center" v-else>
|
||||
<div class="menu-entry__icon menu-entry__icon--disabled">
|
||||
<icon-sync-off></icon-sync-off>
|
||||
</div>
|
||||
<span><b>{{currentWorkspace.name}}</b> 未同步。</span>
|
||||
</div>
|
||||
</div>
|
||||
<menu-entry v-if="!loginToken" @click.native="signin">
|
||||
<icon-login slot="icon"></icon-login>
|
||||
<div>使用 Gitee 登录</div>
|
||||
<span>同步您的主文档空间并解锁功能。</span>
|
||||
</menu-entry>
|
||||
<menu-entry v-if="!loginToken" @click.native="signinWithGithub">
|
||||
<icon-login slot="icon"></icon-login>
|
||||
<div>使用 GitHub 登录</div>
|
||||
<span>同步您的主文档空间并解锁功能。</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="setPanel('workspaces')">
|
||||
<icon-database slot="icon"></icon-database>
|
||||
<div><div class="menu-entry__label menu-entry__label--count" v-if="workspaceCount">{{workspaceCount}}</div> 文档空间</div>
|
||||
<span>切换到另一个文档空间。</span>
|
||||
</menu-entry>
|
||||
<hr>
|
||||
<menu-entry @click.native="setPanel('sync')">
|
||||
<icon-sync slot="icon"></icon-sync>
|
||||
<div><div class="menu-entry__label menu-entry__label--count" v-if="syncLocationCount">{{syncLocationCount}}</div> 同步</div>
|
||||
<span>在云端同步您的文件。</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="setPanel('publish')">
|
||||
<icon-upload slot="icon"></icon-upload>
|
||||
<div><div class="menu-entry__label menu-entry__label--count" v-if="publishLocationCount">{{publishLocationCount}}</div>发布</div>
|
||||
<span>将您的文件导出到 Web。</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="setPanel('history')">
|
||||
<icon-history slot="icon"></icon-history>
|
||||
<div>历史</div>
|
||||
<span>跟踪和恢复文件修订。</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="fileProperties">
|
||||
<icon-view-list slot="icon"></icon-view-list>
|
||||
<div>文件属性</div>
|
||||
<span>添加元数据并配置扩展。</span>
|
||||
</menu-entry>
|
||||
<hr>
|
||||
<menu-entry @click.native="setPanel('toc')">
|
||||
<icon-toc slot="icon"></icon-toc>
|
||||
目录
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="setPanel('help')">
|
||||
<icon-help-circle slot="icon"></icon-help-circle>
|
||||
Markdown 帮助
|
||||
</menu-entry>
|
||||
<hr>
|
||||
<menu-entry @click.native="setPanel('importExport')">
|
||||
<icon-content-save slot="icon"></icon-content-save>
|
||||
导入/导出
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="print">
|
||||
<icon-printer slot="icon"></icon-printer>
|
||||
打印
|
||||
</menu-entry>
|
||||
<hr>
|
||||
<menu-entry @click.native="badges">
|
||||
<icon-seal slot="icon"></icon-seal>
|
||||
<div><div class="menu-entry__label menu-entry__label--count">{{badgeCount}}/{{featureCount}}</div> 徽章</div>
|
||||
<span>列出应用程序功能和获得的徽章。</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="accounts">
|
||||
<icon-key slot="icon"></icon-key>
|
||||
<div><div class="menu-entry__label menu-entry__label--count">{{accountCount}}</div> 账号</div>
|
||||
<span>管理对您的外部账号的访问。</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="templates">
|
||||
<icon-code-braces slot="icon"></icon-code-braces>
|
||||
<div><div class="menu-entry__label menu-entry__label--count">{{templateCount}}</div> 模板</div>
|
||||
<span>为您的导出配置 Handlebars 模板。</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="setPanel('editTheme')">
|
||||
<icon-select-theme slot="icon"></icon-select-theme>
|
||||
编辑区主题
|
||||
<span>编辑区主题样式(自定义主题可编辑)。</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="setPanel('previewTheme')">
|
||||
<icon-select-theme slot="icon"></icon-select-theme>
|
||||
预览区主题
|
||||
<span>预览区主题样式(自定义主题可编辑)。</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="settings">
|
||||
<icon-settings slot="icon"></icon-settings>
|
||||
<div>配置</div>
|
||||
<span>调整应用程序和键盘快捷键。</span>
|
||||
</menu-entry>
|
||||
<hr>
|
||||
<menu-entry @click.native="setPanel('workspaceBackups')">
|
||||
<icon-content-save slot="icon"></icon-content-save>
|
||||
文档空间备份
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="reset">
|
||||
<icon-logout slot="icon"></icon-logout>
|
||||
重置应用程序
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="about">
|
||||
<icon-help-circle slot="icon"></icon-help-circle>
|
||||
关于 StackEdit
|
||||
</menu-entry>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters, mapActions } from 'vuex';
|
||||
import MenuEntry from './common/MenuEntry';
|
||||
import providerRegistry from '../../services/providers/common/providerRegistry';
|
||||
import UserImage from '../UserImage';
|
||||
import giteeHelper from '../../services/providers/helpers/giteeHelper';
|
||||
import githubHelper from '../../services/providers/helpers/githubHelper';
|
||||
import syncSvc from '../../services/syncSvc';
|
||||
import userSvc from '../../services/userSvc';
|
||||
import store from '../../store';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MenuEntry,
|
||||
UserImage,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('workspace', [
|
||||
'currentWorkspace',
|
||||
'syncToken',
|
||||
'loginToken',
|
||||
]),
|
||||
userId() {
|
||||
return userSvc.getCurrentUserId();
|
||||
},
|
||||
workspaceLocationUrl() {
|
||||
const provider = providerRegistry.providersById[this.currentWorkspace.providerId];
|
||||
return provider.getWorkspaceLocationUrl(this.currentWorkspace);
|
||||
},
|
||||
workspaceCount() {
|
||||
return Object.keys(store.getters['workspace/workspacesById']).length;
|
||||
},
|
||||
syncLocationCount() {
|
||||
return Object.keys(store.getters['syncLocation/currentWithWorkspaceSyncLocation']).length;
|
||||
},
|
||||
publishLocationCount() {
|
||||
return Object.keys(store.getters['publishLocation/current']).length;
|
||||
},
|
||||
templateCount() {
|
||||
return Object.keys(store.getters['data/allTemplatesById']).length;
|
||||
},
|
||||
accountCount() {
|
||||
return Object.values(store.getters['data/tokensByType'])
|
||||
.reduce((count, tokensBySub) => count + Object.values(tokensBySub).length, 0);
|
||||
},
|
||||
badgeCount() {
|
||||
return store.getters['data/allBadges'].filter(badge => badge.isEarned).length;
|
||||
},
|
||||
featureCount() {
|
||||
return store.getters['data/allBadges'].length;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions('data', {
|
||||
setPanel: 'setSideBarPanel',
|
||||
}),
|
||||
async signin() {
|
||||
try {
|
||||
await giteeHelper.signin();
|
||||
await syncSvc.afterSignIn();
|
||||
syncSvc.requestSync();
|
||||
} catch (e) {
|
||||
// Cancel
|
||||
}
|
||||
},
|
||||
async signinWithGithub() {
|
||||
try {
|
||||
await githubHelper.signin();
|
||||
await syncSvc.afterSignIn();
|
||||
syncSvc.requestSync();
|
||||
} catch (e) {
|
||||
// Cancel
|
||||
}
|
||||
},
|
||||
async fileProperties() {
|
||||
try {
|
||||
await store.dispatch('modal/open', 'fileProperties');
|
||||
} catch (e) {
|
||||
// Cancel
|
||||
}
|
||||
},
|
||||
print() {
|
||||
window.print();
|
||||
},
|
||||
async settings() {
|
||||
try {
|
||||
await store.dispatch('modal/open', 'settings');
|
||||
} catch (e) { /* Cancel */ }
|
||||
},
|
||||
async templates() {
|
||||
try {
|
||||
await store.dispatch('modal/open', 'templates');
|
||||
} catch (e) { /* Cancel */ }
|
||||
},
|
||||
async accounts() {
|
||||
try {
|
||||
await store.dispatch('modal/open', 'accountManagement');
|
||||
} catch (e) { /* Cancel */ }
|
||||
},
|
||||
async badges() {
|
||||
try {
|
||||
await store.dispatch('modal/open', 'badgeManagement');
|
||||
} catch (e) { /* Cancel */ }
|
||||
},
|
||||
async reset() {
|
||||
try {
|
||||
await store.dispatch('modal/open', 'reset');
|
||||
localStorage.setItem('resetStackEdit', '1');
|
||||
window.location.reload();
|
||||
} catch (e) { /* Cancel */ }
|
||||
},
|
||||
about() {
|
||||
store.dispatch('modal/open', 'about');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
116
src/components/menus/PreviewThemeMenu.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="preview-theme side-bar__panel side-bar__panel--menu">
|
||||
<div class="side-bar__info">
|
||||
<div class="menu-entry menu-entry--info flex flex--row flex--align-center">
|
||||
<span v-if="currPreviewTheme==='custom'">
|
||||
下面的自定义主题样式可编辑,可参考其他主题样式填入自己喜欢的预览样式。<br>
|
||||
主题class为:preview-theme--custom
|
||||
</span>
|
||||
<span v-else>
|
||||
下面的主题样式不可编辑。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-bar__content">
|
||||
<template v-if="currPreviewTheme === 'default'">
|
||||
默认主题无额外样式,请选择其他主题。
|
||||
</template>
|
||||
<template v-else>
|
||||
<code-editor v-for="(value, index) in styleEles" :key="index"
|
||||
v-if="value.id === `preview-theme-${currPreviewTheme}`" lang="css" :value="value.innerHTML"
|
||||
:disabled="value.id!=='preview-theme-custom'" @changed="changeText" scrollClass="side-bar__inner"></code-editor>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex flex--row flex--end" v-if="currPreviewTheme==='custom'">
|
||||
<button class="preview-theme__button button" @click="saveStyleText">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import MenuEntry from './common/MenuEntry';
|
||||
import CodeEditor from '../CodeEditor';
|
||||
import store from '../../store';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MenuEntry,
|
||||
CodeEditor,
|
||||
},
|
||||
data: () => ({
|
||||
themeStyleText: '',
|
||||
styleEles: [],
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters('theme', [
|
||||
'currPreviewTheme',
|
||||
]),
|
||||
},
|
||||
methods: {
|
||||
saveStyleText() {
|
||||
const typeEle = this.findByTheme(this.currPreviewTheme);
|
||||
if (!typeEle || !this.themeStyleText) {
|
||||
return;
|
||||
}
|
||||
typeEle.innerHTML = this.themeStyleText;
|
||||
store.dispatch('theme/setCustomPreviewThemeStyle', this.themeStyleText);
|
||||
store.dispatch('notification/info', '保存自定义主题样式成功!');
|
||||
},
|
||||
findByTheme(theme) {
|
||||
const findEles = this.styleEles.filter(it => it.id === `preview-theme-${theme}`);
|
||||
return findEles.length ? findEles[0] : null;
|
||||
},
|
||||
changeText(text) {
|
||||
this.themeStyleText = text;
|
||||
},
|
||||
close() {
|
||||
store.dispatch('data/setSideBarPanel', 'menu');
|
||||
},
|
||||
initStyle(theme) {
|
||||
if (theme === 'default') {
|
||||
return;
|
||||
}
|
||||
const value = theme || this.currPreviewTheme;
|
||||
if (this.findByTheme(value)) {
|
||||
return;
|
||||
}
|
||||
const styleId = `preview-theme-${value}`;
|
||||
const styleEle = document.getElementById(styleId);
|
||||
if (!styleEle) {
|
||||
setTimeout(() => this.initStyle(value), 1000);
|
||||
return;
|
||||
}
|
||||
this.styleEles.push(styleEle);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currPreviewTheme: {
|
||||
immediate: true,
|
||||
handler(val) {
|
||||
this.initStyle(val);
|
||||
},
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.initStyle();
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.side-bar__panel--menu {
|
||||
.side-bar__content {
|
||||
.code-editor {
|
||||
min-height: 400px !important;
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-theme__button {
|
||||
font-size: 14px;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
307
src/components/menus/PublishMenu.vue
Normal file
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div class="side-bar__panel side-bar__panel--menu">
|
||||
<div class="side-bar__info" v-if="isCurrentTemp">
|
||||
<p>{{currentFileName}} 无法发布,因为它是一个临时文件。</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="side-bar__info" v-if="publishLocations.length">
|
||||
<p>{{currentFileName}} 已经发布。</p>
|
||||
<menu-entry @click.native="requestPublish">
|
||||
<icon-upload slot="icon"></icon-upload>
|
||||
<div>立即发布</div>
|
||||
<span>发布 {{currentFileName}} 的更新。</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="managePublish">
|
||||
<icon-view-list slot="icon"></icon-view-list>
|
||||
<div><div class="menu-entry__label menu-entry__label--count">{{locationCount}}</div> 文件发布</div>
|
||||
<span>管理 {{currentFileName}} 的发布位置。</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<div class="side-bar__info" v-else-if="noToken">
|
||||
<p>您必须链接一个账号才能开始发布文件。</p>
|
||||
</div>
|
||||
<hr>
|
||||
<div v-for="token in bloggerTokens" :key="'blogger-' + token.sub">
|
||||
<menu-entry @click.native="publishBlogger(token)">
|
||||
<icon-provider slot="icon" provider-id="blogger"></icon-provider>
|
||||
<div>发布到 Blogger</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="publishBloggerPage(token)">
|
||||
<icon-provider slot="icon" provider-id="bloggerPage"></icon-provider>
|
||||
<div>发布到 Blogger Page</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<div v-for="token in dropboxTokens" :key="token.sub">
|
||||
<menu-entry @click.native="publishDropbox(token)">
|
||||
<icon-provider slot="icon" provider-id="dropbox"></icon-provider>
|
||||
<div>发布到 Dropbox</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<div v-for="token in githubTokens" :key="token.sub">
|
||||
<menu-entry @click.native="publishGist(token)">
|
||||
<icon-provider slot="icon" provider-id="gist"></icon-provider>
|
||||
<div>发布到 GitHubGist</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="publishGithub(token)">
|
||||
<icon-provider slot="icon" provider-id="github"></icon-provider>
|
||||
<div>发布到 GitHub</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<div v-for="token in giteeTokens" :key="token.sub">
|
||||
<menu-entry @click.native="publishGiteeGist(token)">
|
||||
<icon-provider slot="icon" provider-id="giteegist"></icon-provider>
|
||||
<div>发布到 GiteeGist</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="publishGitee(token)">
|
||||
<icon-provider slot="icon" provider-id="gitee"></icon-provider>
|
||||
<div>发布到 Gitee</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<div v-for="token in gitlabTokens" :key="token.sub">
|
||||
<menu-entry @click.native="publishGitlab(token)">
|
||||
<icon-provider slot="icon" provider-id="gitlab"></icon-provider>
|
||||
<div>发布到 GitLab</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<div v-for="token in giteaTokens" :key="token.sub">
|
||||
<menu-entry @click.native="publishGitea(token)">
|
||||
<icon-provider slot="icon" provider-id="gitea"></icon-provider>
|
||||
<div>发布到 Gitea</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<div v-for="token in googleDriveTokens" :key="token.sub">
|
||||
<menu-entry @click.native="publishGoogleDrive(token)">
|
||||
<icon-provider slot="icon" provider-id="googleDrive"></icon-provider>
|
||||
<div>发布到 Google Drive</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<div v-for="token in wordpressTokens" :key="token.sub">
|
||||
<menu-entry @click.native="publishWordpress(token)">
|
||||
<icon-provider slot="icon" provider-id="wordpress"></icon-provider>
|
||||
<div>发布到 WordPress</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<div v-for="token in zendeskTokens" :key="token.sub">
|
||||
<menu-entry @click.native="publishZendesk(token)">
|
||||
<icon-provider slot="icon" provider-id="zendesk"></icon-provider>
|
||||
<div>发布到 Zendesk Help Center</div>
|
||||
<span>{{token.name}} — {{token.subdomain}}</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<hr>
|
||||
<menu-entry @click.native="addBloggerAccount">
|
||||
<icon-provider slot="icon" provider-id="blogger"></icon-provider>
|
||||
<span>添加 Blogger 账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addDropboxAccount">
|
||||
<icon-provider slot="icon" provider-id="dropbox"></icon-provider>
|
||||
<span>添加 Dropbox 账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGithubAccount">
|
||||
<icon-provider slot="icon" provider-id="github"></icon-provider>
|
||||
<span>添加 GitHub 账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGiteeAccount">
|
||||
<icon-provider slot="icon" provider-id="gitee"></icon-provider>
|
||||
<span>添加 Gitee 账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGitlabAccount">
|
||||
<icon-provider slot="icon" provider-id="gitlab"></icon-provider>
|
||||
<span>添加 GitLab 账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGiteaAccount">
|
||||
<icon-provider slot="icon" provider-id="gitea"></icon-provider>
|
||||
<span>添加 Gitea 账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGoogleDriveAccount">
|
||||
<icon-provider slot="icon" provider-id="googleDrive"></icon-provider>
|
||||
<span>添加 Google Drive 账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addWordpressAccount">
|
||||
<icon-provider slot="icon" provider-id="wordpress"></icon-provider>
|
||||
<span>添加 WordPress 账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addZendeskAccount">
|
||||
<icon-provider slot="icon" provider-id="zendesk"></icon-provider>
|
||||
<span>添加 Zendesk 账号</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapGetters } from 'vuex';
|
||||
import MenuEntry from './common/MenuEntry';
|
||||
import googleHelper from '../../services/providers/helpers/googleHelper';
|
||||
import dropboxHelper from '../../services/providers/helpers/dropboxHelper';
|
||||
import githubHelper from '../../services/providers/helpers/githubHelper';
|
||||
import giteeHelper from '../../services/providers/helpers/giteeHelper';
|
||||
import gitlabHelper from '../../services/providers/helpers/gitlabHelper';
|
||||
import giteaHelper from '../../services/providers/helpers/giteaHelper';
|
||||
import wordpressHelper from '../../services/providers/helpers/wordpressHelper';
|
||||
import zendeskHelper from '../../services/providers/helpers/zendeskHelper';
|
||||
import publishSvc from '../../services/publishSvc';
|
||||
import store from '../../store';
|
||||
|
||||
const tokensToArray = (tokens, filter = () => true) => Object.values(tokens)
|
||||
.filter(token => filter(token))
|
||||
.sort((token1, token2) => token1.name.localeCompare(token2.name));
|
||||
|
||||
const publishModalOpener = (type, featureId) => async (token) => {
|
||||
try {
|
||||
const publishLocation = await store.dispatch('modal/open', {
|
||||
type,
|
||||
token,
|
||||
});
|
||||
publishSvc.createPublishLocation(publishLocation, featureId);
|
||||
} catch (e) { /* cancel */ }
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MenuEntry,
|
||||
},
|
||||
computed: {
|
||||
...mapState('queue', [
|
||||
'isPublishRequested',
|
||||
]),
|
||||
...mapGetters('file', [
|
||||
'isCurrentTemp',
|
||||
]),
|
||||
...mapGetters('publishLocation', {
|
||||
publishLocations: 'current',
|
||||
}),
|
||||
locationCount() {
|
||||
return Object.keys(this.publishLocations).length;
|
||||
},
|
||||
currentFileName() {
|
||||
return `"${store.getters['file/current'].name}"`;
|
||||
},
|
||||
bloggerTokens() {
|
||||
return tokensToArray(store.getters['data/googleTokensBySub'], token => token.isBlogger);
|
||||
},
|
||||
dropboxTokens() {
|
||||
return tokensToArray(store.getters['data/dropboxTokensBySub']);
|
||||
},
|
||||
githubTokens() {
|
||||
return tokensToArray(store.getters['data/githubTokensBySub']);
|
||||
},
|
||||
giteeTokens() {
|
||||
return tokensToArray(store.getters['data/giteeTokensBySub']);
|
||||
},
|
||||
gitlabTokens() {
|
||||
return tokensToArray(store.getters['data/gitlabTokensBySub']);
|
||||
},
|
||||
giteaTokens() {
|
||||
return tokensToArray(store.getters['data/giteaTokensBySub']);
|
||||
},
|
||||
googleDriveTokens() {
|
||||
return tokensToArray(store.getters['data/googleTokensBySub'], token => token.isDrive);
|
||||
},
|
||||
wordpressTokens() {
|
||||
return tokensToArray(store.getters['data/wordpressTokensBySub']);
|
||||
},
|
||||
zendeskTokens() {
|
||||
return tokensToArray(store.getters['data/zendeskTokensBySub']);
|
||||
},
|
||||
noToken() {
|
||||
return !this.bloggerTokens.length
|
||||
&& !this.dropboxTokens.length
|
||||
&& !this.githubTokens.length
|
||||
&& !this.giteeTokens.length
|
||||
&& !this.gitlabTokens.length
|
||||
&& !this.giteaTokens.length
|
||||
&& !this.googleDriveTokens.length
|
||||
&& !this.wordpressTokens.length
|
||||
&& !this.zendeskTokens.length;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
requestPublish() {
|
||||
if (!this.isPublishRequested) {
|
||||
publishSvc.requestPublish();
|
||||
}
|
||||
},
|
||||
async managePublish() {
|
||||
try {
|
||||
await store.dispatch('modal/open', 'publishManagement');
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addBloggerAccount() {
|
||||
try {
|
||||
await googleHelper.addBloggerAccount();
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addDropboxAccount() {
|
||||
try {
|
||||
await store.dispatch('modal/open', { type: 'dropboxAccount' });
|
||||
await dropboxHelper.addAccount(!store.getters['data/localSettings'].dropboxRestrictedAccess);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addGithubAccount() {
|
||||
try {
|
||||
await store.dispatch('modal/open', { type: 'githubAccount' });
|
||||
await githubHelper.addAccount(store.getters['data/localSettings'].githubRepoFullAccess);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addGiteeAccount() {
|
||||
try {
|
||||
await store.dispatch('modal/open', { type: 'giteeAccount' });
|
||||
await giteeHelper.addAccount();
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addGitlabAccount() {
|
||||
try {
|
||||
const { serverUrl, applicationId, applicationSecret } = await store.dispatch('modal/open', { type: 'gitlabAccount' });
|
||||
await gitlabHelper.addAccount(serverUrl, applicationId, applicationSecret);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addGiteaAccount() {
|
||||
try {
|
||||
const applicationInfo = await store.dispatch('modal/open', { type: 'giteaAccount' });
|
||||
await giteaHelper.addAccount(applicationInfo);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addGoogleDriveAccount() {
|
||||
try {
|
||||
await store.dispatch('modal/open', { type: 'googleDriveAccount' });
|
||||
await googleHelper.addDriveAccount(!store.getters['data/localSettings'].googleDriveRestrictedAccess);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addWordpressAccount() {
|
||||
try {
|
||||
await wordpressHelper.addAccount();
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addZendeskAccount() {
|
||||
try {
|
||||
const { subdomain, clientId } = await store.dispatch('modal/open', { type: 'zendeskAccount' });
|
||||
await zendeskHelper.addAccount(subdomain, clientId);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
publishBlogger: publishModalOpener('bloggerPublish', 'publishToBlogger'),
|
||||
publishBloggerPage: publishModalOpener('bloggerPagePublish', 'publishToBloggerPage'),
|
||||
publishDropbox: publishModalOpener('dropboxPublish', 'publishToDropbox'),
|
||||
publishGithub: publishModalOpener('githubPublish', 'publishToGithub'),
|
||||
publishGist: publishModalOpener('gistPublish', 'publishToGist'),
|
||||
publishGitee: publishModalOpener('giteePublish', 'publishToGitee'),
|
||||
publishGiteeGist: publishModalOpener('giteeGistPublish', 'publishGiteeGist'),
|
||||
publishGitlab: publishModalOpener('gitlabPublish', 'publishToGitlab'),
|
||||
publishGitea: publishModalOpener('giteaPublish', 'publishToGitea'),
|
||||
publishGoogleDrive: publishModalOpener('googleDrivePublish', 'publishToGoogleDrive'),
|
||||
publishWordpress: publishModalOpener('wordpressPublish', 'publishToWordPress'),
|
||||
publishZendesk: publishModalOpener('zendeskPublish', 'publishToZendesk'),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
388
src/components/menus/SyncMenu.vue
Normal file
@@ -0,0 +1,388 @@
|
||||
<template>
|
||||
<div class="side-bar__panel side-bar__panel--menu">
|
||||
<div class="side-bar__info" v-if="isCurrentTemp">
|
||||
<p>{{currentFileName}} 无法同步,因为它是临时文件。</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="side-bar__info" v-if="syncLocations.length">
|
||||
<p>{{currentFileName}} 已同步。</p>
|
||||
<menu-entry @click.native="requestSync">
|
||||
<icon-sync slot="icon"></icon-sync>
|
||||
<div>立即同步</div>
|
||||
<span>下载/上载文件更改。</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="manageSync">
|
||||
<icon-view-list slot="icon"></icon-view-list>
|
||||
<div><div class="menu-entry__label menu-entry__label--count">{{locationCount}}</div> 文件同步</div>
|
||||
<span>管理 {{currentFileName}} 的同步位置。</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<div class="side-bar__info" v-else-if="noToken">
|
||||
<p>您必须链接一个账号才能开始同步文件。</p>
|
||||
</div>
|
||||
<hr>
|
||||
<div v-for="token in dropboxTokens" :key="token.sub">
|
||||
<menu-entry @click.native="openDropbox(token)">
|
||||
<icon-provider slot="icon" provider-id="dropbox"></icon-provider>
|
||||
<div>从 Dropbox 打开</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="saveDropbox(token)">
|
||||
<icon-provider slot="icon" provider-id="dropbox"></icon-provider>
|
||||
<div>在Dropbox上保存</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<div v-for="token in githubTokens" :key="token.sub">
|
||||
<menu-entry @click.native="openGithub(token)">
|
||||
<icon-provider slot="icon" provider-id="github"></icon-provider>
|
||||
<div>从 GitHub 打开</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="saveGithub(token)">
|
||||
<icon-provider slot="icon" provider-id="github"></icon-provider>
|
||||
<div>在GitHub上保存</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="saveGist(token)">
|
||||
<icon-provider slot="icon" provider-id="gist"></icon-provider>
|
||||
<div>在GitHubGist上保存</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<div v-for="token in giteeTokens" :key="token.sub">
|
||||
<menu-entry @click.native="openGitee(token)">
|
||||
<icon-provider slot="icon" provider-id="gitee"></icon-provider>
|
||||
<div>从 Gitee 打开</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="saveGitee(token)">
|
||||
<icon-provider slot="icon" provider-id="gitee"></icon-provider>
|
||||
<div>在Gitee上保存</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="saveGiteeGist(token)">
|
||||
<icon-provider slot="icon" provider-id="giteegist"></icon-provider>
|
||||
<div>在GiteeGist上保存</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<div v-for="token in gitlabTokens" :key="token.sub">
|
||||
<menu-entry @click.native="openGitlab(token)">
|
||||
<icon-provider slot="icon" provider-id="gitlab"></icon-provider>
|
||||
<div>从 GitLab 打开</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="saveGitlab(token)">
|
||||
<icon-provider slot="icon" provider-id="gitlab"></icon-provider>
|
||||
<div>在GitLab上保存</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<div v-for="token in giteaTokens" :key="token.sub">
|
||||
<menu-entry @click.native="openGitea(token)">
|
||||
<icon-provider slot="icon" provider-id="gitea"></icon-provider>
|
||||
<div>从 Gitea 打开</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="saveGitea(token)">
|
||||
<icon-provider slot="icon" provider-id="gitea"></icon-provider>
|
||||
<div>在Gitea上保存</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<div v-for="token in googleDriveTokens" :key="token.sub">
|
||||
<menu-entry @click.native="openGoogleDrive(token)">
|
||||
<icon-provider slot="icon" provider-id="googleDrive"></icon-provider>
|
||||
<div>从 Google Drive 打开</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="saveGoogleDrive(token)">
|
||||
<icon-provider slot="icon" provider-id="googleDrive"></icon-provider>
|
||||
<div>在Google Drive上保存</div>
|
||||
<span>{{token.name}}</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<hr>
|
||||
<menu-entry @click.native="addDropboxAccount">
|
||||
<icon-provider slot="icon" provider-id="dropbox"></icon-provider>
|
||||
<span>添加 Dropbox 账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGithubAccount">
|
||||
<icon-provider slot="icon" provider-id="github"></icon-provider>
|
||||
<span>添加 GitHub 账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGiteeAccount">
|
||||
<icon-provider slot="icon" provider-id="gitee"></icon-provider>
|
||||
<span>添加 Gitee 账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGitlabAccount">
|
||||
<icon-provider slot="icon" provider-id="gitlab"></icon-provider>
|
||||
<span>添加 GitLab 账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGiteaAccount">
|
||||
<icon-provider slot="icon" provider-id="gitea"></icon-provider>
|
||||
<span>添加 Gitea 账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGoogleDriveAccount">
|
||||
<icon-provider slot="icon" provider-id="googleDrive"></icon-provider>
|
||||
<span>添加 Google Drive 账号</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapGetters } from 'vuex';
|
||||
import MenuEntry from './common/MenuEntry';
|
||||
import googleHelper from '../../services/providers/helpers/googleHelper';
|
||||
import dropboxHelper from '../../services/providers/helpers/dropboxHelper';
|
||||
import githubHelper from '../../services/providers/helpers/githubHelper';
|
||||
import giteeHelper from '../../services/providers/helpers/giteeHelper';
|
||||
import gitlabHelper from '../../services/providers/helpers/gitlabHelper';
|
||||
import giteaHelper from '../../services/providers/helpers/giteaHelper';
|
||||
import googleDriveProvider from '../../services/providers/googleDriveProvider';
|
||||
import dropboxProvider from '../../services/providers/dropboxProvider';
|
||||
import githubProvider from '../../services/providers/githubProvider';
|
||||
import giteeProvider from '../../services/providers/giteeProvider';
|
||||
import gitlabProvider from '../../services/providers/gitlabProvider';
|
||||
import giteaProvider from '../../services/providers/giteaProvider';
|
||||
import syncSvc from '../../services/syncSvc';
|
||||
import store from '../../store';
|
||||
import badgeSvc from '../../services/badgeSvc';
|
||||
|
||||
const tokensToArray = (tokens, filter = () => true) => Object.values(tokens)
|
||||
.filter(token => filter(token))
|
||||
.sort((token1, token2) => token1.name.localeCompare(token2.name));
|
||||
|
||||
const openSyncModal = (token, type) => store.dispatch('modal/open', {
|
||||
type,
|
||||
token,
|
||||
}).then(syncLocation => syncSvc.createSyncLocation(syncLocation));
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MenuEntry,
|
||||
},
|
||||
computed: {
|
||||
...mapState('queue', [
|
||||
'isSyncRequested',
|
||||
]),
|
||||
...mapGetters('workspace', [
|
||||
'syncToken',
|
||||
]),
|
||||
...mapGetters('file', [
|
||||
'isCurrentTemp',
|
||||
]),
|
||||
...mapGetters('syncLocation', {
|
||||
syncLocations: 'currentWithWorkspaceSyncLocation',
|
||||
}),
|
||||
locationCount() {
|
||||
return Object.keys(this.syncLocations).length;
|
||||
},
|
||||
currentFileName() {
|
||||
return `"${store.getters['file/current'].name}"`;
|
||||
},
|
||||
dropboxTokens() {
|
||||
return tokensToArray(store.getters['data/dropboxTokensBySub']);
|
||||
},
|
||||
githubTokens() {
|
||||
return tokensToArray(store.getters['data/githubTokensBySub']);
|
||||
},
|
||||
giteeTokens() {
|
||||
return tokensToArray(store.getters['data/giteeTokensBySub']);
|
||||
},
|
||||
gitlabTokens() {
|
||||
return tokensToArray(store.getters['data/gitlabTokensBySub']);
|
||||
},
|
||||
giteaTokens() {
|
||||
return tokensToArray(store.getters['data/giteaTokensBySub']);
|
||||
},
|
||||
googleDriveTokens() {
|
||||
return tokensToArray(store.getters['data/googleTokensBySub'], token => token.isDrive);
|
||||
},
|
||||
noToken() {
|
||||
return !this.googleDriveTokens.length
|
||||
&& !this.dropboxTokens.length
|
||||
&& !this.githubTokens.length
|
||||
&& !this.giteeTokens.length;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
requestSync() {
|
||||
if (!this.isSyncRequested) {
|
||||
syncSvc.requestSync(true);
|
||||
}
|
||||
},
|
||||
async manageSync() {
|
||||
try {
|
||||
await store.dispatch('modal/open', 'syncManagement');
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addDropboxAccount() {
|
||||
try {
|
||||
await store.dispatch('modal/open', { type: 'dropboxAccount' });
|
||||
await dropboxHelper.addAccount(!store.getters['data/localSettings'].dropboxRestrictedAccess);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addGithubAccount() {
|
||||
try {
|
||||
await store.dispatch('modal/open', { type: 'githubAccount' });
|
||||
await githubHelper.addAccount(store.getters['data/localSettings'].githubRepoFullAccess);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addGiteeAccount() {
|
||||
try {
|
||||
await store.dispatch('modal/open', { type: 'giteeAccount' });
|
||||
await giteeHelper.addAccount();
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addGitlabAccount() {
|
||||
try {
|
||||
const { serverUrl, applicationId, applicationSecret } = await store.dispatch('modal/open', { type: 'gitlabAccount' });
|
||||
await gitlabHelper.addAccount(serverUrl, applicationId, applicationSecret);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addGiteaAccount() {
|
||||
try {
|
||||
const applicationInfo = await store.dispatch('modal/open', { type: 'giteaAccount' });
|
||||
await giteaHelper.addAccount(applicationInfo);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addGoogleDriveAccount() {
|
||||
try {
|
||||
await store.dispatch('modal/open', { type: 'googleDriveAccount' });
|
||||
await googleHelper.addDriveAccount(!store.getters['data/localSettings'].googleDriveRestrictedAccess);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async openDropbox(token) {
|
||||
const paths = await dropboxHelper.openChooser(token);
|
||||
store.dispatch(
|
||||
'queue/enqueue',
|
||||
async () => {
|
||||
await dropboxProvider.openFiles(token, paths);
|
||||
badgeSvc.addBadge('openFromDropbox');
|
||||
},
|
||||
);
|
||||
},
|
||||
async saveDropbox(token) {
|
||||
try {
|
||||
await openSyncModal(token, 'dropboxSave');
|
||||
badgeSvc.addBadge('saveOnDropbox');
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async openGoogleDrive(token) {
|
||||
const files = await googleHelper.openPicker(token, 'doc');
|
||||
store.dispatch(
|
||||
'queue/enqueue',
|
||||
async () => {
|
||||
await googleDriveProvider.openFiles(token, files);
|
||||
badgeSvc.addBadge('openFromGoogleDrive');
|
||||
},
|
||||
);
|
||||
},
|
||||
async saveGoogleDrive(token) {
|
||||
try {
|
||||
await openSyncModal(token, 'googleDriveSave');
|
||||
badgeSvc.addBadge('saveOnGoogleDrive');
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async openGithub(token) {
|
||||
try {
|
||||
const syncLocation = await store.dispatch('modal/open', {
|
||||
type: 'githubOpen',
|
||||
token,
|
||||
});
|
||||
store.dispatch(
|
||||
'queue/enqueue',
|
||||
async () => {
|
||||
await githubProvider.openFile(token, syncLocation);
|
||||
badgeSvc.addBadge('openFromGithub');
|
||||
},
|
||||
);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async saveGithub(token) {
|
||||
try {
|
||||
await openSyncModal(token, 'githubSave');
|
||||
badgeSvc.addBadge('saveOnGithub');
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async openGitee(token) {
|
||||
try {
|
||||
const syncLocation = await store.dispatch('modal/open', {
|
||||
type: 'giteeOpen',
|
||||
token,
|
||||
});
|
||||
store.dispatch(
|
||||
'queue/enqueue',
|
||||
async () => {
|
||||
await giteeProvider.openFile(token, syncLocation);
|
||||
badgeSvc.addBadge('openFromGitee');
|
||||
},
|
||||
);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async saveGitee(token) {
|
||||
try {
|
||||
await openSyncModal(token, 'giteeSave');
|
||||
badgeSvc.addBadge('saveOnGitee');
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async saveGist(token) {
|
||||
try {
|
||||
await openSyncModal(token, 'gistSync');
|
||||
badgeSvc.addBadge('saveOnGist');
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async saveGiteeGist(token) {
|
||||
try {
|
||||
await openSyncModal(token, 'giteeGistSync');
|
||||
badgeSvc.addBadge('saveOnGiteeGist');
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async openGitlab(token) {
|
||||
try {
|
||||
const syncLocation = await store.dispatch('modal/open', {
|
||||
type: 'gitlabOpen',
|
||||
token,
|
||||
});
|
||||
store.dispatch(
|
||||
'queue/enqueue',
|
||||
async () => {
|
||||
await gitlabProvider.openFile(token, syncLocation);
|
||||
badgeSvc.addBadge('openFromGitlab');
|
||||
},
|
||||
);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async openGitea(token) {
|
||||
try {
|
||||
const syncLocation = await store.dispatch('modal/open', {
|
||||
type: 'giteaOpen',
|
||||
token,
|
||||
});
|
||||
store.dispatch(
|
||||
'queue/enqueue',
|
||||
async () => {
|
||||
await giteaProvider.openFile(token, syncLocation);
|
||||
badgeSvc.addBadge('openFromGitea');
|
||||
},
|
||||
);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async saveGitlab(token) {
|
||||
try {
|
||||
await openSyncModal(token, 'gitlabSave');
|
||||
badgeSvc.addBadge('saveOnGitlab');
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async saveGitea(token) {
|
||||
try {
|
||||
await openSyncModal(token, 'giteaSave');
|
||||
badgeSvc.addBadge('saveOnGitea');
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
64
src/components/menus/WorkspaceBackupMenu.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="side-bar__panel side-bar__panel--menu">
|
||||
<input class="hidden-file" id="import-backup-file-input" type="file" @change="onImportBackup">
|
||||
<label class="menu-entry button flex flex--row flex--align-center" for="import-backup-file-input">
|
||||
<div class="menu-entry__icon flex flex--column flex--center">
|
||||
<icon-content-save></icon-content-save>
|
||||
</div>
|
||||
<div class="flex flex--column">
|
||||
导入文档空间备份
|
||||
</div>
|
||||
</label>
|
||||
<menu-entry @click.native="exportWorkspace">
|
||||
<icon-content-save slot="icon"></icon-content-save>
|
||||
导出文档空间备份
|
||||
</menu-entry>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FileSaver from 'file-saver';
|
||||
import MenuEntry from './common/MenuEntry';
|
||||
import store from '../../store';
|
||||
import backupSvc from '../../services/backupSvc';
|
||||
import localDbSvc from '../../services/localDbSvc';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MenuEntry,
|
||||
},
|
||||
computed: {
|
||||
workspaceId: () => store.getters['workspace/currentWorkspace'].id,
|
||||
},
|
||||
methods: {
|
||||
onImportBackup(evt) {
|
||||
const file = evt.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target.result;
|
||||
if (text.match(/\uFFFD/)) {
|
||||
store.dispatch('notification/error', 'File is not readable.');
|
||||
} else {
|
||||
backupSvc.importBackup(text);
|
||||
}
|
||||
};
|
||||
const blob = file.slice(0, 10000000);
|
||||
reader.readAsText(blob);
|
||||
}
|
||||
},
|
||||
exportWorkspace() {
|
||||
const allItemsById = {};
|
||||
localDbSvc.getWorkspaceItems(this.workspaceId, (item) => {
|
||||
allItemsById[item.id] = item;
|
||||
}, () => {
|
||||
const backup = JSON.stringify(allItemsById);
|
||||
const blob = new Blob([backup], {
|
||||
type: 'text/plain;charset=utf-8',
|
||||
});
|
||||
FileSaver.saveAs(blob, 'StackEdit workspace.json');
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
137
src/components/menus/WorkspacesMenu.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="side-bar__panel side-bar__panel--menu">
|
||||
<menu-entry @click.native="manageWorkspaces">
|
||||
<icon-database slot="icon"></icon-database>
|
||||
<div><div class="menu-entry__label menu-entry__label--count">{{workspaceCount}}</div> 管理文档空间</div>
|
||||
<span>列出、重命名、删除文档空间</span>
|
||||
</menu-entry>
|
||||
<hr>
|
||||
<div class="workspace" v-for="(workspace, id) in workspacesById" :key="id">
|
||||
<menu-entry :href="workspace.url" target="_blank">
|
||||
<icon-provider v-if="id === 'main' && !workspace.sub" slot="icon" :provider-id="'stackedit'"></icon-provider>
|
||||
<icon-provider v-else slot="icon" :provider-id="workspace.providerId"></icon-provider>
|
||||
<div class="workspace__name"><div class="menu-entry__label" v-if="currentWorkspace === workspace">当前</div>{{workspace.name}}</div>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<hr>
|
||||
<menu-entry @click.native="addGithubWorkspace">
|
||||
<icon-provider slot="icon" provider-id="githubWorkspace"></icon-provider>
|
||||
<span>新增 <b>GitHub</b> 文档空间</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGiteeWorkspace">
|
||||
<icon-provider slot="icon" provider-id="giteeWorkspace"></icon-provider>
|
||||
<span>新增 <b>Gitee</b> 文档空间</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGitlabWorkspace">
|
||||
<icon-provider slot="icon" provider-id="gitlabWorkspace"></icon-provider>
|
||||
<span>新增 <b>GitLab</b> 文档空间</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGiteaWorkspace">
|
||||
<icon-provider slot="icon" provider-id="giteaWorkspace"></icon-provider>
|
||||
<span>新增 <b>Gitea</b> 文档空间</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGoogleDriveWorkspace">
|
||||
<icon-provider slot="icon" provider-id="googleDriveWorkspace"></icon-provider>
|
||||
<span>新增 <b>Google Drive</b> 文档空间</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addCouchdbWorkspace">
|
||||
<icon-provider slot="icon" provider-id="couchdbWorkspace"></icon-provider>
|
||||
<span>新增 <b>CouchDB</b> 文档空间</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import MenuEntry from './common/MenuEntry';
|
||||
import googleHelper from '../../services/providers/helpers/googleHelper';
|
||||
import gitlabHelper from '../../services/providers/helpers/gitlabHelper';
|
||||
import giteaHelper from '../../services/providers/helpers/giteaHelper';
|
||||
import store from '../../store';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MenuEntry,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('workspace', [
|
||||
'workspacesById',
|
||||
'currentWorkspace',
|
||||
]),
|
||||
workspaceCount() {
|
||||
return Object.keys(this.workspacesById).length;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async addCouchdbWorkspace() {
|
||||
try {
|
||||
store.dispatch('modal/open', {
|
||||
type: 'couchdbWorkspace',
|
||||
});
|
||||
} catch (e) { /* Cancel */ }
|
||||
},
|
||||
async addGithubWorkspace() {
|
||||
try {
|
||||
store.dispatch('modal/open', {
|
||||
type: 'githubWorkspace',
|
||||
});
|
||||
} catch (e) { /* Cancel */ }
|
||||
},
|
||||
async addGiteeWorkspace() {
|
||||
try {
|
||||
store.dispatch('modal/open', {
|
||||
type: 'giteeWorkspace',
|
||||
});
|
||||
} catch (e) { /* Cancel */ }
|
||||
},
|
||||
async addGitlabWorkspace() {
|
||||
try {
|
||||
const { serverUrl, applicationId, applicationSecret } = await store.dispatch('modal/open', { type: 'gitlabAccount' });
|
||||
const token = await gitlabHelper.addAccount(serverUrl, applicationId, applicationSecret);
|
||||
store.dispatch('modal/open', {
|
||||
type: 'gitlabWorkspace',
|
||||
token,
|
||||
});
|
||||
} catch (e) { /* Cancel */ }
|
||||
},
|
||||
async addGiteaWorkspace() {
|
||||
try {
|
||||
const applicationInfo = await store.dispatch('modal/open', { type: 'giteaAccount' });
|
||||
const token = await giteaHelper.addAccount(applicationInfo);
|
||||
store.dispatch('modal/open', {
|
||||
type: 'giteaWorkspace',
|
||||
token,
|
||||
});
|
||||
} catch (e) { /* Cancel */ }
|
||||
},
|
||||
async addGoogleDriveWorkspace() {
|
||||
try {
|
||||
const token = await googleHelper.addDriveAccount(true);
|
||||
store.dispatch('modal/open', {
|
||||
type: 'googleDriveWorkspace',
|
||||
token,
|
||||
});
|
||||
} catch (e) { /* Cancel */ }
|
||||
},
|
||||
manageWorkspaces() {
|
||||
try {
|
||||
store.dispatch('modal/open', 'workspaceManagement');
|
||||
} catch (e) { /* Cancel */ }
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
.workspace .menu-entry {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.workspace__name {
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
97
src/components/menus/common/MenuEntry.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<a class="menu-entry button flex flex--row flex--align-center" href="javascript:void(0)">
|
||||
<div class="menu-entry__icon flex flex--column flex--center">
|
||||
<slot name="icon"></slot>
|
||||
</div>
|
||||
<div class="menu-entry__text flex flex--column">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
.menu-entry {
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
height: auto;
|
||||
font-size: 17px;
|
||||
line-height: 1.4;
|
||||
text-transform: none;
|
||||
white-space: normal;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.67;
|
||||
line-height: 1.3;
|
||||
|
||||
.menu-entry__label {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-entry--info {
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.menu-entry__icon {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
margin-right: 12px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.menu-entry__icon--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.menu-entry__icon--image {
|
||||
border-radius: $border-radius-base;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hidden-file {
|
||||
position: fixed;
|
||||
top: -999px;
|
||||
}
|
||||
|
||||
.menu-entry__label {
|
||||
float: right;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
padding: 0.15em 0.25em;
|
||||
background-color: #fff;
|
||||
border-radius: 3px;
|
||||
opacity: 0.6;
|
||||
|
||||
.app--dark & {
|
||||
background-color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-entry__label--warning {
|
||||
color: #fff;
|
||||
background-color: darken($error-color, 10);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.menu-entry__label--count {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.menu-entry__text {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
93
src/components/menus/common/MenuItem.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="menu-item flex flex--row flex--align-center">
|
||||
<div class="menu-item__icon flex flex--column flex--center">
|
||||
<slot name="icon"></slot>
|
||||
</div>
|
||||
<div class="menu-item__text flex flex--column">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
.menu-item {
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
height: auto;
|
||||
font-size: 17px;
|
||||
line-height: 1.4;
|
||||
text-transform: none;
|
||||
white-space: normal;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.67;
|
||||
line-height: 1.3;
|
||||
|
||||
.menu-item__label {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-item--info {
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.menu-item__icon {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
margin-right: 12px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.menu-item__icon--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.menu-item__icon--image {
|
||||
border-radius: $border-radius-base;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hidden-file {
|
||||
position: fixed;
|
||||
top: -999px;
|
||||
}
|
||||
|
||||
.menu-item__label {
|
||||
float: right;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
padding: 0.15em 0.25em;
|
||||
background-color: #fff;
|
||||
border-radius: 3px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.menu-item__label--warning {
|
||||
color: #fff;
|
||||
background-color: darken($error-color, 10);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.menu-item__label--count {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.menu-item__text {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
76
src/components/modals/AboutModal.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<modal-inner class="modal__inner-1--about-modal" aria-label="About">
|
||||
<div class="modal__content">
|
||||
<div class="logo-background"></div>
|
||||
StackEdit 在 <a target="_blank" href="https://gitee.com/mafgwo/stackedit/">Gitee</a> 上
|
||||
<br>
|
||||
<a target="_blank" href="https://gitee.com/mafgwo/stackedit/issues">问题跟踪</a> — <a target="_blank" href="https://gitee.com/mafgwo/stackedit/releases">更新日志</a>
|
||||
<br>
|
||||
<a target="_blank" href="#">Chrome 应用</a> — <a target="_blank" href="#">Chrome 扩展</a>
|
||||
<br>
|
||||
<hr>
|
||||
<small>© 2022 StackEdit中文版<br>v{{version}}</small>
|
||||
<h3>常见问题解答</h3>
|
||||
<div class="faq" v-html="faq"></div>
|
||||
<div class="modal__info">
|
||||
如需商业支持或定制开发,请 <a href="mailto:mafgwo@163.com">联系我们</a>.
|
||||
</div>
|
||||
Licensed under an
|
||||
<a target="_blank" href="http://www.apache.org/licenses/LICENSE-2.0">Apache License</a><br>
|
||||
<a target="_blank" href="privacy_policy.html">隐私策略</a>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button button--resolve" @click="config.resolve()">关闭</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import ModalInner from './common/ModalInner';
|
||||
import markdownConversionSvc from '../../services/markdownConversionSvc';
|
||||
import faq from '../../data/faq.md';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ModalInner,
|
||||
},
|
||||
data: () => ({
|
||||
version: VERSION,
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters('modal', [
|
||||
'config',
|
||||
]),
|
||||
faq() {
|
||||
return markdownConversionSvc.defaultConverter.render(faq);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.modal__inner-1--about-modal {
|
||||
text-align: center;
|
||||
|
||||
.logo-background {
|
||||
height: 75px;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 160px;
|
||||
max-width: 100%;
|
||||
margin: 1.5em auto;
|
||||
}
|
||||
}
|
||||
|
||||
.faq {
|
||||
font-size: 0.8em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
369
src/components/modals/AccountManagementModal.vue
Normal file
@@ -0,0 +1,369 @@
|
||||
<template>
|
||||
<modal-inner class="modal__inner-1--account-management" aria-label="管理外部账号">
|
||||
<div class="modal__content">
|
||||
<div class="modal__image">
|
||||
<icon-key></icon-key>
|
||||
</div>
|
||||
<p v-if="entries.length">StackEdit中文版可以访问以下外部账号:</p>
|
||||
<p v-else>StackEdit中文版尚未访问任何外部账号。</p>
|
||||
<div>
|
||||
<div class="account-entry flex flex--column" v-for="entry in entries" :key="entry.token.sub">
|
||||
<div class="account-entry__header flex flex--row flex--align-center">
|
||||
<div class="account-entry__icon flex flex--column flex--center">
|
||||
<icon-provider :provider-id="entry.providerId"></icon-provider>
|
||||
</div>
|
||||
<div class="account-entry__description">
|
||||
{{entry.name}}
|
||||
</div>
|
||||
<div class="account-entry__buttons flex flex--row flex--center">
|
||||
<button class="account-entry__button button" @click="remove(entry)" v-title="'删除访问'">
|
||||
<icon-delete></icon-delete>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="account-entry__row">
|
||||
<span class="account-entry__field" v-if="entry.userId">
|
||||
<b>用户ID:</b>
|
||||
{{entry.userId}}
|
||||
</span>
|
||||
<span class="account-entry__field" v-if="entry.url">
|
||||
<b>URL:</b>
|
||||
{{entry.url}}
|
||||
</span>
|
||||
<span class="account-entry__field line-entry" v-if="entry.customHeaders">
|
||||
<b>自定义请求头:</b>
|
||||
{{entry.customHeaders}}
|
||||
</span>
|
||||
<span class="account-entry__field line-entry" v-if="entry.customParams">
|
||||
<b>自定义Form参数:</b>
|
||||
{{entry.customParams}}
|
||||
</span>
|
||||
<span class="account-entry__field" v-if="entry.scopes">
|
||||
<b>权限范围:</b>
|
||||
{{entry.scopes.join(', ')}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<menu-entry @click.native="addBloggerAccount">
|
||||
<icon-provider slot="icon" provider-id="blogger"></icon-provider>
|
||||
<span>添加Blogger账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addDropboxAccount">
|
||||
<icon-provider slot="icon" provider-id="dropbox"></icon-provider>
|
||||
<span>添加Dropbox账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGithubAccount">
|
||||
<icon-provider slot="icon" provider-id="github"></icon-provider>
|
||||
<span>添加GitHub账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGiteeAccount">
|
||||
<icon-provider slot="icon" provider-id="gitee"></icon-provider>
|
||||
<span>添加Gitee账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGitlabAccount">
|
||||
<icon-provider slot="icon" provider-id="gitlab"></icon-provider>
|
||||
<span>添加GitLab账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGiteaAccount">
|
||||
<icon-provider slot="icon" provider-id="gitea"></icon-provider>
|
||||
<span>添加Gitea账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGoogleDriveAccount">
|
||||
<icon-provider slot="icon" provider-id="googleDrive"></icon-provider>
|
||||
<span>添加Google Drive账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGooglePhotosAccount">
|
||||
<icon-provider slot="icon" provider-id="googlePhotos"></icon-provider>
|
||||
<span>添加Google Photos账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addWordpressAccount">
|
||||
<icon-provider slot="icon" provider-id="wordpress"></icon-provider>
|
||||
<span>添加WordPress账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addZendeskAccount">
|
||||
<icon-provider slot="icon" provider-id="zendesk"></icon-provider>
|
||||
<span>添加Zendesk账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addSmmsAccount">
|
||||
<icon-provider slot="icon" provider-id="smms"></icon-provider>
|
||||
<span>添加SM.MS账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addCustomAccount">
|
||||
<icon-provider slot="icon" provider-id="custom"></icon-provider>
|
||||
<span>添加自定义图床账号</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button button--resolve" @click="config.resolve()">关闭</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import ModalInner from './common/ModalInner';
|
||||
import MenuEntry from '../menus/common/MenuEntry';
|
||||
import store from '../../store';
|
||||
import utils from '../../services/utils';
|
||||
import googleHelper from '../../services/providers/helpers/googleHelper';
|
||||
import dropboxHelper from '../../services/providers/helpers/dropboxHelper';
|
||||
import githubHelper from '../../services/providers/helpers/githubHelper';
|
||||
import giteeHelper from '../../services/providers/helpers/giteeHelper';
|
||||
import gitlabHelper from '../../services/providers/helpers/gitlabHelper';
|
||||
import giteaHelper from '../../services/providers/helpers/giteaHelper';
|
||||
import wordpressHelper from '../../services/providers/helpers/wordpressHelper';
|
||||
import zendeskHelper from '../../services/providers/helpers/zendeskHelper';
|
||||
import smmsHelper from '../../services/providers/helpers/smmsHelper';
|
||||
import customHelper from '../../services/providers/helpers/customHelper';
|
||||
import badgeSvc from '../../services/badgeSvc';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ModalInner,
|
||||
MenuEntry,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('modal', [
|
||||
'config',
|
||||
]),
|
||||
entries() {
|
||||
return [
|
||||
...Object.values(store.getters['data/googleTokensBySub']).map(token => ({
|
||||
token,
|
||||
providerId: 'google',
|
||||
userId: token.sub,
|
||||
name: token.name,
|
||||
scopes: ['openid', 'profile', ...token.scopes
|
||||
.map(scope => scope.replace(/^https:\/\/www.googleapis.com\/auth\//, ''))],
|
||||
})),
|
||||
...Object.values(store.getters['data/couchdbTokensBySub']).map(token => ({
|
||||
token,
|
||||
providerId: 'couchdb',
|
||||
url: token.dbUrl,
|
||||
name: token.name,
|
||||
})),
|
||||
...Object.values(store.getters['data/dropboxTokensBySub']).map(token => ({
|
||||
token,
|
||||
providerId: 'dropbox',
|
||||
userId: token.sub,
|
||||
name: token.name,
|
||||
})),
|
||||
...Object.values(store.getters['data/githubTokensBySub']).map(token => ({
|
||||
token,
|
||||
providerId: 'github',
|
||||
userId: token.sub,
|
||||
name: token.name,
|
||||
scopes: token.scopes,
|
||||
})),
|
||||
...Object.values(store.getters['data/giteeTokensBySub']).map(token => ({
|
||||
token,
|
||||
providerId: 'gitee',
|
||||
userId: token.sub,
|
||||
name: token.name,
|
||||
scopes: ['projects', 'pull_requests'],
|
||||
})),
|
||||
...Object.values(store.getters['data/gitlabTokensBySub']).map(token => ({
|
||||
token,
|
||||
providerId: 'gitlab',
|
||||
url: token.serverUrl,
|
||||
userId: token.sub,
|
||||
name: token.name,
|
||||
scopes: ['api'],
|
||||
})),
|
||||
...Object.values(store.getters['data/giteaTokensBySub']).map(token => ({
|
||||
token,
|
||||
providerId: 'gitea',
|
||||
url: token.serverUrl,
|
||||
userId: token.sub,
|
||||
name: token.name,
|
||||
scopes: ['api'],
|
||||
})),
|
||||
...Object.values(store.getters['data/wordpressTokensBySub']).map(token => ({
|
||||
token,
|
||||
providerId: 'wordpress',
|
||||
userId: token.sub,
|
||||
name: token.name,
|
||||
scopes: ['global'],
|
||||
})),
|
||||
...Object.values(store.getters['data/zendeskTokensBySub']).map(token => ({
|
||||
token,
|
||||
providerId: 'zendesk',
|
||||
url: `https://${token.subdomain}.zendesk.com/`,
|
||||
userId: token.sub,
|
||||
name: token.name,
|
||||
scopes: ['read', 'hc:write'],
|
||||
})),
|
||||
...Object.values(store.getters['data/smmsTokensBySub']).map(token => ({
|
||||
token,
|
||||
providerId: 'smms',
|
||||
userId: token.sub,
|
||||
name: token.name,
|
||||
scopes: ['api'],
|
||||
})),
|
||||
...Object.values(store.getters['data/customTokensBySub']).map(token => ({
|
||||
token,
|
||||
providerId: 'custom',
|
||||
url: token.uploadUrl,
|
||||
userId: token.name,
|
||||
name: token.name,
|
||||
customHeaders: token.customHeaders && JSON.stringify(token.customHeaders),
|
||||
customParams: token.customParams && JSON.stringify(token.customParams),
|
||||
scopes: ['upload'],
|
||||
})),
|
||||
];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async remove(entry) {
|
||||
const tokensBySub = utils.deepCopy(store.getters[`data/${entry.providerId}TokensBySub`]);
|
||||
delete tokensBySub[entry.token.sub];
|
||||
await store.dispatch('data/patchTokensByType', {
|
||||
[entry.providerId]: tokensBySub,
|
||||
});
|
||||
badgeSvc.addBadge('removeAccount');
|
||||
},
|
||||
async addBloggerAccount() {
|
||||
try {
|
||||
await googleHelper.addBloggerAccount();
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addDropboxAccount() {
|
||||
try {
|
||||
await store.dispatch('modal/open', { type: 'dropboxAccount' });
|
||||
await dropboxHelper.addAccount(!store.getters['data/localSettings'].dropboxRestrictedAccess);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addGithubAccount() {
|
||||
try {
|
||||
await store.dispatch('modal/open', { type: 'githubAccount' });
|
||||
await githubHelper.addAccount(store.getters['data/localSettings'].githubRepoFullAccess);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addGiteeAccount() {
|
||||
try {
|
||||
await store.dispatch('modal/open', { type: 'giteeAccount' });
|
||||
await giteeHelper.addAccount();
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addGitlabAccount() {
|
||||
try {
|
||||
const { serverUrl, applicationId, applicationSecret } = await store.dispatch('modal/open', { type: 'gitlabAccount' });
|
||||
await gitlabHelper.addAccount(serverUrl, applicationId, applicationSecret);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addGiteaAccount() {
|
||||
try {
|
||||
const applicationInfo = await store.dispatch('modal/open', { type: 'giteaAccount' });
|
||||
await giteaHelper.addAccount(applicationInfo);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addGoogleDriveAccount() {
|
||||
try {
|
||||
await store.dispatch('modal/open', { type: 'googleDriveAccount' });
|
||||
await googleHelper.addDriveAccount(!store.getters['data/localSettings'].googleDriveRestrictedAccess);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addGooglePhotosAccount() {
|
||||
try {
|
||||
await googleHelper.addPhotosAccount();
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addWordpressAccount() {
|
||||
try {
|
||||
await wordpressHelper.addAccount();
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addZendeskAccount() {
|
||||
try {
|
||||
const { subdomain, clientId } = await store.dispatch('modal/open', { type: 'zendeskAccount' });
|
||||
await zendeskHelper.addAccount(subdomain, clientId);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addSmmsAccount() {
|
||||
try {
|
||||
const { proxyUrl, apiSecretToken } = await store.dispatch('modal/open', { type: 'smmsAccount' });
|
||||
await smmsHelper.addAccount(proxyUrl, apiSecretToken);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
async addCustomAccount() {
|
||||
try {
|
||||
const accountInfo = await store.dispatch('modal/open', { type: 'customAccount' });
|
||||
await customHelper.addAccount(accountInfo);
|
||||
} catch (e) { /* cancel */ }
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
.line-entry {
|
||||
word-break: break-word; /* 文本行的任意字内断开,就算是一个单词也会分开 */
|
||||
word-wrap: break-word; /* IE */
|
||||
white-space: -moz-pre-wrap; /* Mozilla */
|
||||
white-space: -hp-pre-wrap; /* HP printers */
|
||||
white-space: -o-pre-wrap; /* Opera 7 */
|
||||
white-space: -pre-wrap; /* Opera 4-6 */
|
||||
white-space: pre; /* CSS2 */
|
||||
white-space: pre-wrap; /* CSS 2.1 */
|
||||
white-space: pre-line; /* CSS 3 (and 2.1 as well, actually) */
|
||||
}
|
||||
|
||||
.account-entry {
|
||||
margin: 1.5em 0;
|
||||
height: auto;
|
||||
font-size: 17px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
$button-size: 30px;
|
||||
|
||||
.account-entry__header {
|
||||
line-height: $button-size;
|
||||
}
|
||||
|
||||
.account-entry__row {
|
||||
border-top: 1px solid $hr-color;
|
||||
font-size: 0.67em;
|
||||
padding: 0.25em 0;
|
||||
}
|
||||
|
||||
.account-entry__field {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.account-entry__icon {
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
margin-right: 0.75rem;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.account-entry__description {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.account-entry__buttons {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.account-entry__button {
|
||||
width: $button-size;
|
||||
height: $button-size;
|
||||
padding: 4px;
|
||||
background-color: transparent;
|
||||
opacity: 0.75;
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
124
src/components/modals/BadgeManagementModal.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<modal-inner class="modal__inner-1--badge-management" aria-label="管理徽章">
|
||||
<div class="modal__content">
|
||||
<div class="modal__image">
|
||||
<icon-seal></icon-seal>
|
||||
</div>
|
||||
<p v-if="badgeCount > 1">获得了{{badgeCount}}徽章</p>
|
||||
<p v-else>获得了{{badgeCount}}徽章</p>
|
||||
<div class="badge-entry" v-for="badge in badgeTree" :key="badge.featureId">
|
||||
<div class="flex flex--row">
|
||||
<icon-seal class="badge-entry__icon" :class="{'badge-entry__icon--earned': badge.isEarned, 'badge-entry__icon--some-earned': badge.hasSomeEarned}"></icon-seal>
|
||||
<div>
|
||||
<span class="badge-entry__name" :class="{'badge-entry__name--earned': badge.isEarned, 'badge-entry__name--some-earned': badge.hasSomeEarned}">{{badge.name}}</span>
|
||||
<span class="badge-entry__description">— {{badge.description}}</span>
|
||||
<a href="javascript:void(0)" v-if="!shown[badge.featureId]" @click="show(badge.featureId)">展开</a>
|
||||
<div class="badge-entry" v-else v-for="child in badge.children" :key="child.featureId">
|
||||
<div class="flex flex--row">
|
||||
<icon-seal class="badge-entry__icon" :class="{'badge-entry__icon--earned': child.isEarned}"></icon-seal>
|
||||
<div>
|
||||
<span class="badge-entry__name" :class="{'badge-entry__name--earned': child.isEarned}">{{child.name}}</span>
|
||||
<span class="badge-entry__description">— {{child.description}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button button--resolve" @click="config.resolve()">关闭</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import ModalInner from './common/ModalInner';
|
||||
import store from '../../store';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ModalInner,
|
||||
},
|
||||
data: () => ({
|
||||
shown: {},
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters('modal', [
|
||||
'config',
|
||||
]),
|
||||
...mapGetters('data', [
|
||||
'badgeTree',
|
||||
]),
|
||||
badgeCount() {
|
||||
return store.getters['data/allBadges'].filter(badge => badge.isEarned).length;
|
||||
},
|
||||
featureCount() {
|
||||
return store.getters['data/allBadges'].length;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
show(featureId) {
|
||||
Vue.set(this.shown, featureId, true);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
.modal__inner-1.modal__inner-1--badge-management {
|
||||
max-width: 520px;
|
||||
|
||||
p {
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-entry {
|
||||
line-height: 1.4;
|
||||
margin: 2rem 0;
|
||||
font-size: 0.9em;
|
||||
|
||||
.badge-entry {
|
||||
font-size: 0.8em;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-entry__icon {
|
||||
width: 1.67em;
|
||||
height: 1.67em;
|
||||
margin-right: 0.25em;
|
||||
opacity: 0.3;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.badge-entry__icon--some-earned {
|
||||
opacity: 0.5;
|
||||
color: goldenrod;
|
||||
}
|
||||
|
||||
.badge-entry__icon--earned {
|
||||
opacity: 1;
|
||||
color: goldenrod;
|
||||
}
|
||||
|
||||
.badge-entry__description {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.badge-entry__name {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.badge-entry__name--earned {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
131
src/components/modals/ChatGptModal.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<modal-inner class="modal__inner-1--chatgpt" aria-label="chatgpt">
|
||||
<div class="modal__content">
|
||||
<div class="modal__image">
|
||||
<icon-chat-gpt></icon-chat-gpt>
|
||||
</div>
|
||||
<p><b>ChatGPT内容生成</b><br>生成时长受ChatGPT服务响应与网络响应时长影响,时间可能较长</p>
|
||||
<form-entry label="生成内容要求详细描述" error="content">
|
||||
<textarea slot="field" class="text-input" type="text" placeholder="输入内容(支持换行)" v-model.trim="content" :disabled="generating"></textarea>
|
||||
<div class="form-entry__info">
|
||||
使用 <a href="https://api35.pxj123.cn/" target="_blank">api35.pxj123.cn</a> 的免费接口生成内容,AI模型是:GPT-3.5 Turbo。
|
||||
</div>
|
||||
</form-entry>
|
||||
<div class="modal__result">
|
||||
<pre class="result_pre" v-if="generating && !result">(等待生成中...)</pre>
|
||||
<pre class="result_pre" v-else v-text="result"></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="reject()">{{ generating ? '停止' : '关闭' }}</button>
|
||||
<button class="button button--resolve" @click="generate" v-if="!generating && !!content">{{ !!result ? '重新生成' : '开始生成' }}</button>
|
||||
<button class="button button--resolve" @click="resolve" v-if="!generating && !!result">确认插入</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import modalTemplate from './common/modalTemplate';
|
||||
import chatGptSvc from '../../services/chatGptSvc';
|
||||
import store from '../../store';
|
||||
|
||||
export default modalTemplate({
|
||||
data: () => ({
|
||||
generating: false,
|
||||
content: '',
|
||||
result: '',
|
||||
xhr: null,
|
||||
}),
|
||||
methods: {
|
||||
resolve(evt) {
|
||||
evt.preventDefault();
|
||||
const { callback } = this.config;
|
||||
this.config.resolve();
|
||||
callback(this.result);
|
||||
},
|
||||
process({ done, content, error }) {
|
||||
if (done) {
|
||||
this.generating = false;
|
||||
// 已结束
|
||||
} else if (content) {
|
||||
this.result = this.result + content;
|
||||
const container = document.querySelector('.result_pre');
|
||||
container.scrollTo(0, container.scrollHeight); // 滚动到最底部
|
||||
} else if (error) {
|
||||
this.generating = false;
|
||||
}
|
||||
},
|
||||
generate() {
|
||||
this.generating = true;
|
||||
this.result = '';
|
||||
try {
|
||||
this.xhr = chatGptSvc.chat({
|
||||
content: `${this.content}\n(使用Markdown方式输出结果)`,
|
||||
}, this.process);
|
||||
} catch (err) {
|
||||
this.generating = false;
|
||||
store.dispatch('notification/error', err);
|
||||
}
|
||||
},
|
||||
reject() {
|
||||
if (this.generating) {
|
||||
if (this.xhr) {
|
||||
this.xhr.abort();
|
||||
this.generating = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { callback } = this.config;
|
||||
this.config.reject();
|
||||
callback(null);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const script = document.createElement('script');
|
||||
script.src = `https://api35.pxj123.cn/js/chat.js?t=${new Date().getTime()}`;
|
||||
script.onload = () => {
|
||||
/* eslint-disable */
|
||||
console.log('加载外部chatgpt的js成功!');
|
||||
};
|
||||
this.$el.appendChild(script);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
.modal__inner-1.modal__inner-1--chatgpt {
|
||||
max-width: 560px;
|
||||
|
||||
.result_pre {
|
||||
font-size: 0.9em;
|
||||
font-variant-ligatures: no-common-ligatures;
|
||||
line-height: 1.25;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
height: 300px;
|
||||
border: 1px solid rgb(126, 126, 126);
|
||||
border-radius: $border-radius-base;
|
||||
padding: 10px;
|
||||
overflow-y: scroll; /* 开启垂直滚动条 */
|
||||
}
|
||||
|
||||
.result_pre::-webkit-scrollbar {
|
||||
display: none; /* 隐藏滚动条 */
|
||||
}
|
||||
|
||||
.result_pre.scroll-bottom {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.config-warning {
|
||||
color: #f00;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
min-height: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
43
src/components/modals/CommitMessageModal.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<modal-inner aria-label="提交信息">
|
||||
<p>自定义 <b>{{ config.name }}</b> 提交信息。</p>
|
||||
<div class="modal__content">
|
||||
<div class="form-entry">
|
||||
<label class="form-entry__label">提交信息</label>
|
||||
<div class="form-entry__field">
|
||||
<input slot="field" class="textfield" placeholder="提交信息非必填" type="text" v-model.trim="commitMessage" @keydown.enter="resolve()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="config.reject()">取消</button>
|
||||
<button class="button button--resolve" @click="resolve()">确认</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import ModalInner from './common/ModalInner';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ModalInner,
|
||||
},
|
||||
data: () => ({
|
||||
commitMessage: '',
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters('modal', [
|
||||
'config',
|
||||
]),
|
||||
},
|
||||
methods: {
|
||||
resolve() {
|
||||
this.config.resolve({
|
||||
commitMessage: this.commitMessage,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
253
src/components/modals/FilePropertiesModal.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<modal-inner class="modal__inner-1--file-properties" aria-label="文件属性">
|
||||
<div class="modal__content">
|
||||
<div class="tabs flex flex--row">
|
||||
<tab :active="tab === 'simple'" @click="setSimpleTab()">
|
||||
简单属性
|
||||
</tab>
|
||||
<tab :active="tab === 'yaml'" @click="setYamlTab()">
|
||||
YAML属性
|
||||
</tab>
|
||||
</div>
|
||||
<div v-if="tab === 'simple'">
|
||||
<div class="modal__title">扩展</div>
|
||||
<div class="modal__sub-title">配置Markdown引擎。</div>
|
||||
<form-entry label="Preset">
|
||||
<select slot="field" class="textfield" v-model="preset" @keydown.enter="resolve()">
|
||||
<option v-for="(preset, id) in presets" :key="id" :value="preset">
|
||||
{{ preset }}
|
||||
</option>
|
||||
</select>
|
||||
</form-entry>
|
||||
<div class="modal__title">元数据</div>
|
||||
<div class="modal__sub-title">将信息添加到您的发布(WordPress,Blogger ...)。</div>
|
||||
<form-entry label="Title">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="title" @keydown.enter="resolve()">
|
||||
</form-entry>
|
||||
<form-entry label="Author">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="author" @keydown.enter="resolve()">
|
||||
</form-entry>
|
||||
<form-entry label="Tags" info="comma-separated">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="tags" @keydown.enter="resolve()">
|
||||
</form-entry>
|
||||
<form-entry label="Categories" info="comma-separated">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="categories" @keydown.enter="resolve()">
|
||||
</form-entry>
|
||||
<form-entry label="Excerpt">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="excerpt" @keydown.enter="resolve()">
|
||||
</form-entry>
|
||||
<form-entry label="Featured image">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="featuredImage" @keydown.enter="resolve()">
|
||||
</form-entry>
|
||||
<form-entry label="Status">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="status" @keydown.enter="resolve()">
|
||||
<div class="form-entry__info">
|
||||
<b>示例:</b>草稿
|
||||
</div>
|
||||
</form-entry>
|
||||
<form-entry label="Date" info="YYYY-MM-DD">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="date" @keydown.enter="resolve()">
|
||||
</form-entry>
|
||||
</div>
|
||||
<div v-if="tab === 'yaml'">
|
||||
<div class="form-entry" role="tabpanel" aria-label="YAML properties">
|
||||
<label class="form-entry__label">YAML</label>
|
||||
<div class="form-entry__field">
|
||||
<code-editor lang="yaml" :value="yamlProperties" key="custom-properties" @changed="setYamlProperties"></code-editor>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__error modal__error--file-properties">{{error}}</div>
|
||||
<div class="modal__info modal__info--multiline">
|
||||
<p><strong>提示:</strong> 您可以手动切换扩展名:</p>
|
||||
<pre class=" language-yaml"><code class="prism language-yaml"><span class="token key atrule">extensions</span><span class="token punctuation">:</span>
|
||||
<span class="token key atrule">emoji</span><span class="token punctuation">:</span>
|
||||
<span class="token comment"># 启用表情符号快捷方式如 :) :-(</span>
|
||||
<span class="token key atrule">shortcuts</span><span class="token punctuation">:</span> <span class="token boolean important">true</span>
|
||||
</code></pre>
|
||||
<p>使用预设<code>zero</code>制作自己的配置:</p>
|
||||
<pre class=" language-yaml"><code class="prism language-yaml"><span class="token key atrule">extensions</span><span class="token punctuation">:</span>
|
||||
<span class="token key atrule">preset</span><span class="token punctuation">:</span> zero
|
||||
<span class="token key atrule">markdown</span><span class="token punctuation">:</span>
|
||||
<span class="token key atrule">table</span><span class="token punctuation">:</span> <span class="token boolean important">true</span>
|
||||
<span class="token key atrule">katex</span><span class="token punctuation">:</span>
|
||||
<span class="token key atrule">enabled</span><span class="token punctuation">:</span> <span class="token boolean important">true</span>
|
||||
</code></pre>
|
||||
<p>有关选项的完整列表,请参阅 <a href="https://gitee.com/mafgwo/stackedit/blob/master/src/data/presets.js" target="_blank">这里</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="config.reject()">取消</button>
|
||||
<button class="button button--resolve" @click="resolve()">确认</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import yaml from 'js-yaml';
|
||||
import { mapGetters } from 'vuex';
|
||||
import ModalInner from './common/ModalInner';
|
||||
import Tab from './common/Tab';
|
||||
import FormEntry from './common/FormEntry';
|
||||
import CodeEditor from '../CodeEditor';
|
||||
import utils from '../../services/utils';
|
||||
import presets from '../../data/presets';
|
||||
import store from '../../store';
|
||||
import badgeSvc from '../../services/badgeSvc';
|
||||
|
||||
const metadataProperties = {
|
||||
title: '',
|
||||
author: '',
|
||||
tags: '',
|
||||
categories: '',
|
||||
excerpt: '',
|
||||
featuredImage: '',
|
||||
status: '',
|
||||
date: '',
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ModalInner,
|
||||
Tab,
|
||||
FormEntry,
|
||||
CodeEditor,
|
||||
},
|
||||
data: () => ({
|
||||
contentId: null,
|
||||
yamlProperties: null,
|
||||
preset: '',
|
||||
error: null,
|
||||
...metadataProperties,
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters('modal', [
|
||||
'config',
|
||||
]),
|
||||
presets: () => Object.keys(presets).sort(),
|
||||
tab: {
|
||||
get() {
|
||||
return store.getters['data/localSettings'].filePropertiesTab;
|
||||
},
|
||||
set(value) {
|
||||
store.dispatch('data/patchLocalSettings', {
|
||||
filePropertiesTab: value,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
created() {
|
||||
const content = store.getters['content/current'];
|
||||
this.contentId = content.id;
|
||||
this.setYamlProperties(content.properties);
|
||||
if (this.tab !== 'yaml') {
|
||||
this.setSimpleTab();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
yamlToSimple() {
|
||||
const properties = this.properties || {};
|
||||
const extensions = properties.extensions || {};
|
||||
this.preset = extensions.preset;
|
||||
if (!this.presets.includes(this.preset)) {
|
||||
this.preset = 'default';
|
||||
}
|
||||
Object.keys(metadataProperties).forEach((name) => {
|
||||
this[name] = `${properties[name] || ''}`;
|
||||
});
|
||||
},
|
||||
simpleToYaml() {
|
||||
let hasChanged = false;
|
||||
const properties = this.properties || {};
|
||||
const extensions = properties.extensions || {};
|
||||
if (this.preset !== extensions.preset) {
|
||||
if (this.preset !== 'default') {
|
||||
extensions.preset = this.preset;
|
||||
hasChanged = true;
|
||||
} else if (extensions.preset) {
|
||||
delete extensions.preset;
|
||||
hasChanged = true;
|
||||
}
|
||||
}
|
||||
Object.keys(metadataProperties).forEach((name) => {
|
||||
if (this[name] !== properties[name]) {
|
||||
if (this[name]) {
|
||||
properties[name] = this[name];
|
||||
hasChanged = true;
|
||||
} else if (properties[name]) {
|
||||
delete properties[name];
|
||||
hasChanged = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (hasChanged) {
|
||||
if (Object.keys(extensions).length) {
|
||||
properties.extensions = extensions;
|
||||
} else {
|
||||
delete properties.extensions;
|
||||
}
|
||||
this.setYamlProperties(Object.keys(properties).length
|
||||
? yaml.safeDump(properties)
|
||||
: '\n');
|
||||
}
|
||||
},
|
||||
setSimpleTab() {
|
||||
this.tab = 'simple';
|
||||
this.yamlToSimple();
|
||||
},
|
||||
setYamlTab() {
|
||||
this.tab = 'yaml';
|
||||
this.simpleToYaml();
|
||||
},
|
||||
setYamlProperties(value) {
|
||||
this.yamlProperties = value;
|
||||
try {
|
||||
this.properties = yaml.safeLoad(value);
|
||||
this.error = null;
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
}
|
||||
},
|
||||
resolve() {
|
||||
if (this.tab === 'simple') {
|
||||
// Compute YAML properties
|
||||
this.simpleToYaml();
|
||||
}
|
||||
if (this.error) {
|
||||
this.setYamlTab();
|
||||
} else {
|
||||
const properties = this.properties || {};
|
||||
if (Object.keys(metadataProperties).some(key => properties[key])) {
|
||||
badgeSvc.addBadge('setMetadata');
|
||||
}
|
||||
const extensions = properties.extensions || {};
|
||||
if (extensions.preset) {
|
||||
badgeSvc.addBadge('changePreset');
|
||||
}
|
||||
if (Object.keys(extensions).filter(key => key !== 'preset').length) {
|
||||
badgeSvc.addBadge('changeExtension');
|
||||
}
|
||||
store.commit('content/patchItem', {
|
||||
id: this.contentId,
|
||||
properties: utils.sanitizeText(this.yamlProperties),
|
||||
});
|
||||
this.config.resolve();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
.modal__inner-1.modal__inner-1--file-properties {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.modal__error--file-properties {
|
||||
white-space: pre-wrap;
|
||||
font-family: $font-family-monospace;
|
||||
font-size: $font-size-monospace;
|
||||
}
|
||||
</style>
|
||||
67
src/components/modals/HtmlExportModal.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<modal-inner aria-label="导出到HTML">
|
||||
<div class="modal__content">
|
||||
<p>请为您的<b> HTML导出</b>选择模板。</p>
|
||||
<form-entry label="模板">
|
||||
<select class="textfield" slot="field" v-model="selectedTemplate" @keydown.enter="resolve()">
|
||||
<option v-for="(template, id) in allTemplatesById" :key="id" :value="id">
|
||||
{{ template.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="form-entry__actions">
|
||||
<a href="javascript:void(0)" @click="configureTemplates">配置模板</a>
|
||||
</div>
|
||||
</form-entry>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button button--copy" v-clipboard="result" @click="info('HTML复制到剪贴板!')">复制</button>
|
||||
<button class="button" @click="config.reject()">取消</button>
|
||||
<button class="button button--resolve" @click="resolve()">确认</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions } from 'vuex';
|
||||
import exportSvc from '../../services/exportSvc';
|
||||
import modalTemplate from './common/modalTemplate';
|
||||
import store from '../../store';
|
||||
import badgeSvc from '../../services/badgeSvc';
|
||||
|
||||
export default modalTemplate({
|
||||
data: () => ({
|
||||
result: '',
|
||||
}),
|
||||
computedLocalSettings: {
|
||||
selectedTemplate: 'htmlExportTemplate',
|
||||
},
|
||||
mounted() {
|
||||
let timeoutId;
|
||||
this.$watch('selectedTemplate', (selectedTemplate) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(async () => {
|
||||
const currentFile = store.getters['file/current'];
|
||||
const html = await exportSvc.applyTemplate(
|
||||
currentFile.id,
|
||||
this.allTemplatesById[selectedTemplate],
|
||||
);
|
||||
this.result = html;
|
||||
}, 10);
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
...mapActions('notification', [
|
||||
'info',
|
||||
]),
|
||||
async resolve() {
|
||||
const { config } = this;
|
||||
const currentFile = store.getters['file/current'];
|
||||
config.resolve();
|
||||
await exportSvc.exportToDisk(currentFile.id, 'html', this.allTemplatesById[this.selectedTemplate]);
|
||||
badgeSvc.addBadge('exportHtml');
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
314
src/components/modals/ImageModal.vue
Normal file
@@ -0,0 +1,314 @@
|
||||
<template>
|
||||
<modal-inner aria-label="插入图像">
|
||||
<div class="modal__content">
|
||||
<p>请为您的图像提供<b> url </b>。<span v-if="uploading">(图片上传中...)</span></p>
|
||||
<form-entry label="URL" error="url">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="url" @keydown.enter="resolve">
|
||||
</form-entry>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<input class="hidden-file" id="upload-image-file-input" type="file" accept="image/*" :disabled="uploading" @change="uploadImage">
|
||||
<label for="upload-image-file-input"><a class="button">上传图片</a></label>
|
||||
<button class="button" @click="reject()">取消</button>
|
||||
<button class="button button--resolve" @click="resolve" :disabled="uploading">确认</button>
|
||||
</div>
|
||||
<div>
|
||||
<hr />
|
||||
<p>添加并选择图床后可在编辑区中粘贴/拖拽图片自动上传</p>
|
||||
|
||||
<menu-entry @click.native="checkedImgDest(path)" v-for="path in workspaceImgPath" :key="path">
|
||||
<icon-check-circle v-if="checkedStorage.sub === path" slot="icon"></icon-check-circle>
|
||||
<icon-check-circle-un v-if="checkedStorage.sub !== path" slot="icon"></icon-check-circle-un>
|
||||
<menu-item>
|
||||
<icon-provider slot="icon" :provider-id="currentWorkspace.providerId"></icon-provider>
|
||||
<div>
|
||||
当前文档空间图片路径
|
||||
<button class="menu-item__button button" @click.stop="removeByPath(path)" v-title="'删除'">
|
||||
<icon-delete></icon-delete>
|
||||
</button>
|
||||
</div>
|
||||
<span>路径:{{path}}</span>
|
||||
</menu-item>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="checkedImgDest(token.sub, token.providerId)" v-for="token in imageTokens" :key="token.sub">
|
||||
<icon-check-circle v-if="checkedStorage.sub === token.sub" slot="icon"></icon-check-circle>
|
||||
<icon-check-circle-un v-if="checkedStorage.sub !== token.sub" slot="icon"></icon-check-circle-un>
|
||||
<menu-item>
|
||||
<icon-provider slot="icon" :provider-id="token.providerId"></icon-provider>
|
||||
<div>
|
||||
{{ token.remark }}
|
||||
<button class="menu-item__button button" @click.stop="remove(token.providerId, token)" v-title="'删除'">
|
||||
<icon-delete></icon-delete>
|
||||
</button>
|
||||
</div>
|
||||
<span>{{token.name}}</span>
|
||||
<span class="line-entry" v-if="token.uploadUrl">上传地址:{{token.uploadUrl}}</span>
|
||||
<span class="line-entry" v-if="token.headers">自定义请求头:{{token.headers}}</span>
|
||||
<span class="line-entry" v-if="token.params">自定义Form参数:{{token.params}}</span>
|
||||
</menu-item>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="checkedImgDest(tokenStorage.token.sub, tokenStorage.providerId, tokenStorage.sid)" v-for="tokenStorage in tokensImgStorages" :key="tokenStorage.sid">
|
||||
<icon-check-circle v-if="checkedStorage.sid === tokenStorage.sid" slot="icon"></icon-check-circle>
|
||||
<icon-check-circle-un v-if="checkedStorage.sid !== tokenStorage.sid" slot="icon"></icon-check-circle-un>
|
||||
<menu-item>
|
||||
<icon-provider slot="icon" :provider-id="tokenStorage.providerId"></icon-provider>
|
||||
<div>{{tokenStorage.providerName}}
|
||||
<button class="menu-item__button button" @click.stop="remove(tokenStorage.providerId, tokenStorage)" v-title="'删除'">
|
||||
<icon-delete></icon-delete>
|
||||
</button>
|
||||
</div>
|
||||
<span> {{tokenStorage.uname}}, 仓库URL: {{tokenStorage.repoUrl}}, 路径: {{tokenStorage.path}}, 分支: {{tokenStorage.branch}}</span>
|
||||
</menu-item>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addWorkspaceImgPath">
|
||||
<icon-provider slot="icon" :provider-id="currentWorkspace.providerId"></icon-provider>
|
||||
<span>添加当前文档空间图片路径</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addSmmsAccount">
|
||||
<icon-provider slot="icon" provider-id="smms"></icon-provider>
|
||||
<span>添加SM.MS图床账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addCustomAccount">
|
||||
<icon-provider slot="icon" provider-id="custom"></icon-provider>
|
||||
<span>添加自定义图床账号</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGiteaImgStorage">
|
||||
<icon-provider slot="icon" provider-id="gitea"></icon-provider>
|
||||
<span>添加Gitea图床仓库</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="addGithubImgStorage">
|
||||
<icon-provider slot="icon" provider-id="github"></icon-provider>
|
||||
<span>添加GitHub图床仓库</span>
|
||||
</menu-entry>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import modalTemplate from './common/modalTemplate';
|
||||
import MenuEntry from '../menus/common/MenuEntry';
|
||||
import MenuItem from '../menus/common/MenuItem';
|
||||
import smmsHelper from '../../services/providers/helpers/smmsHelper';
|
||||
import store from '../../store';
|
||||
import giteaHelper from '../../services/providers/helpers/giteaHelper';
|
||||
import githubHelper from '../../services/providers/helpers/githubHelper';
|
||||
import customHelper from '../../services/providers/helpers/customHelper';
|
||||
import utils from '../../services/utils';
|
||||
import imageSvc from '../../services/imageSvc';
|
||||
|
||||
export default modalTemplate({
|
||||
components: {
|
||||
MenuEntry,
|
||||
MenuItem,
|
||||
},
|
||||
data: () => ({
|
||||
uploading: false,
|
||||
url: '',
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters('workspace', [
|
||||
'currentWorkspace',
|
||||
'currentWorkspaceIsGit',
|
||||
]),
|
||||
checkedStorage() {
|
||||
return store.getters['img/getCheckedStorage'];
|
||||
},
|
||||
workspaceImgPath() {
|
||||
if (!this.currentWorkspaceIsGit) {
|
||||
return [];
|
||||
}
|
||||
const workspaceImgPath = store.getters['img/getWorkspaceImgPath'];
|
||||
return Object.keys(workspaceImgPath || {});
|
||||
},
|
||||
imageTokens() {
|
||||
return [
|
||||
...Object.values(store.getters['data/smmsTokensBySub']).map(token => ({
|
||||
...token,
|
||||
providerId: 'smms',
|
||||
remark: 'SM.MS图床',
|
||||
})),
|
||||
...Object.values(store.getters['data/customTokensBySub']).map(token => ({
|
||||
...token,
|
||||
providerId: 'custom',
|
||||
headers: token.customHeaders && JSON.stringify(token.customHeaders),
|
||||
params: token.customParams && JSON.stringify(token.customParams),
|
||||
remark: '自定义图床',
|
||||
})),
|
||||
];
|
||||
},
|
||||
tokensImgStorages() {
|
||||
const providerTokens = [
|
||||
...Object.values(store.getters['data/giteaTokensBySub']).map(token => ({
|
||||
token,
|
||||
providerId: 'gitea',
|
||||
providerName: 'Gitea图床',
|
||||
})),
|
||||
...Object.values(store.getters['data/githubTokensBySub']).map(token => ({
|
||||
token,
|
||||
providerId: 'github',
|
||||
providerName: 'GitHub图床',
|
||||
})),
|
||||
];
|
||||
const imgStorages = [];
|
||||
Object.values(providerTokens)
|
||||
.sort((item1, item2) => item1.token.name.localeCompare(item2.token.name))
|
||||
.forEach((it) => {
|
||||
if (!it.token.imgStorages || it.token.imgStorages.length === 0) {
|
||||
return;
|
||||
}
|
||||
// 拼接上当前用户名
|
||||
it.token.imgStorages.forEach(storage => imgStorages.push({
|
||||
...storage,
|
||||
token: it.token,
|
||||
uname: it.token.name,
|
||||
providerId: it.providerId,
|
||||
providerName: it.providerName,
|
||||
repoUrl: it.providerId === 'gitea' ? `${it.token.serverUrl}/${storage.repoUri}` : `${storage.owner}/${storage.repo}`,
|
||||
}));
|
||||
});
|
||||
return imgStorages;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
resolve(evt) {
|
||||
evt.preventDefault(); // Fixes https://github.com/mafgwo/stackedit/issues/1503
|
||||
if (!this.url) {
|
||||
this.setError('url');
|
||||
} else {
|
||||
const { callback } = this.config;
|
||||
this.config.resolve();
|
||||
callback(this.url);
|
||||
}
|
||||
},
|
||||
reject() {
|
||||
const { callback } = this.config;
|
||||
this.config.reject();
|
||||
callback(null);
|
||||
},
|
||||
async uploadImage(evt) {
|
||||
if (!evt.target.files || !evt.target.files.length) {
|
||||
return;
|
||||
}
|
||||
const imgFile = evt.target.files[0];
|
||||
try {
|
||||
this.uploading = true;
|
||||
const { url, error } = await imageSvc.updateImg(imgFile);
|
||||
if (error) {
|
||||
store.dispatch('notification/error', error);
|
||||
return;
|
||||
}
|
||||
this.url = url;
|
||||
} catch (err) {
|
||||
store.dispatch('notification/error', err);
|
||||
} finally {
|
||||
this.uploading = false;
|
||||
// 上传后清空
|
||||
evt.target.value = '';
|
||||
}
|
||||
},
|
||||
async remove(proivderId, item) {
|
||||
try {
|
||||
await store.dispatch('modal/open', 'imgStorageDeletion');
|
||||
if (proivderId === 'smms' || proivderId === 'custom') {
|
||||
const tokensBySub = utils.deepCopy(store.getters[`data/${proivderId}TokensBySub`]);
|
||||
delete tokensBySub[item.sub];
|
||||
// 删除账号
|
||||
await store.dispatch('data/patchTokensByType', {
|
||||
[proivderId]: tokensBySub,
|
||||
});
|
||||
} else if (proivderId === 'gitea') {
|
||||
giteaHelper.removeTokenImgStorage(item.token, item.sid);
|
||||
} else if (proivderId === 'github') {
|
||||
githubHelper.removeTokenImgStorage(item.token, item.sid);
|
||||
}
|
||||
} catch (e) {
|
||||
// Cancel
|
||||
}
|
||||
},
|
||||
async removeByPath(path) {
|
||||
store.dispatch('img/removeWorkspaceImgPath', path);
|
||||
},
|
||||
async addWorkspaceImgPath() {
|
||||
const { path } = await store.dispatch('modal/open', { type: 'workspaceImgPath' });
|
||||
store.dispatch('img/addWorkspaceImgPath', path);
|
||||
},
|
||||
async addSmmsAccount() {
|
||||
const { proxyUrl, apiSecretToken } = await store.dispatch('modal/open', { type: 'smmsAccount' });
|
||||
await smmsHelper.addAccount(proxyUrl, apiSecretToken);
|
||||
},
|
||||
async addCustomAccount() {
|
||||
const accountInfo = await store.dispatch('modal/open', { type: 'customAccount' });
|
||||
await customHelper.addAccount(accountInfo);
|
||||
},
|
||||
async addGiteaImgStorage() {
|
||||
try {
|
||||
const applicationInfo = await store.dispatch('modal/open', { type: 'giteaAccount' });
|
||||
const token = await giteaHelper.addAccount(applicationInfo);
|
||||
const imgStorageInfo = await store.dispatch('modal/open', {
|
||||
type: 'giteaImgStorage',
|
||||
token,
|
||||
});
|
||||
giteaHelper.updateToken(token, imgStorageInfo);
|
||||
} catch (e) { /* Cancel */ }
|
||||
},
|
||||
async addGithubImgStorage() {
|
||||
try {
|
||||
await store.dispatch('modal/open', { type: 'githubAccount' });
|
||||
const token = await githubHelper.addAccount(store.getters['data/localSettings'].githubRepoFullAccess);
|
||||
const imgStorageInfo = await store.dispatch('modal/open', {
|
||||
type: 'githubImgStorage',
|
||||
token,
|
||||
});
|
||||
githubHelper.updateToken(token, imgStorageInfo);
|
||||
} catch (e) { /* Cancel */ }
|
||||
},
|
||||
async checkedImgDest(sub, provider, sid) {
|
||||
let type = 'token';
|
||||
// 当前文档空间存储
|
||||
if (!provider) {
|
||||
type = 'workspace';
|
||||
} else if (provider === 'gitea' || provider === 'github') {
|
||||
type = 'tokenRepo';
|
||||
}
|
||||
store.dispatch('img/changeCheckedStorage', {
|
||||
type,
|
||||
provider,
|
||||
sub,
|
||||
sid,
|
||||
});
|
||||
// const { callback } = this.config;
|
||||
// this.config.reject();
|
||||
// const res = await googleHelper.openPicker(token, 'img');
|
||||
// if (res[0]) {
|
||||
// store.dispatch('modal/open', {
|
||||
// type: 'googlePhoto',
|
||||
// url: res[0].url,
|
||||
// callback,
|
||||
// });
|
||||
// }
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.line-entry {
|
||||
word-break: break-word; /* 文本行的任意字内断开,就算是一个单词也会分开 */
|
||||
word-wrap: break-word; /* IE */
|
||||
white-space: -moz-pre-wrap; /* Mozilla */
|
||||
white-space: -hp-pre-wrap; /* HP printers */
|
||||
white-space: -o-pre-wrap; /* Opera 7 */
|
||||
white-space: -pre-wrap; /* Opera 4-6 */
|
||||
white-space: pre; /* CSS2 */
|
||||
white-space: pre-wrap; /* CSS 2.1 */
|
||||
white-space: pre-line; /* CSS 3 (and 2.1 as well, actually) */
|
||||
}
|
||||
|
||||
.menu-item__button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 4px;
|
||||
background-color: transparent;
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
41
src/components/modals/LinkModal.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<modal-inner aria-label="插入链接">
|
||||
<div class="modal__content">
|
||||
<p>请为您的链接提供<b> url </b>。</p>
|
||||
<form-entry label="URL" error="url">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="url" @keydown.enter="resolve">
|
||||
</form-entry>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="reject()">取消</button>
|
||||
<button class="button button--resolve" @click="resolve">确认</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import modalTemplate from './common/modalTemplate';
|
||||
|
||||
export default modalTemplate({
|
||||
data: () => ({
|
||||
url: '',
|
||||
}),
|
||||
methods: {
|
||||
resolve(evt) {
|
||||
evt.preventDefault(); // Fixes https://github.com/mafgwo/stackedit/issues/1503
|
||||
if (!this.url) {
|
||||
this.setError('url');
|
||||
} else {
|
||||
const { callback } = this.config;
|
||||
this.config.resolve();
|
||||
callback(this.url);
|
||||
}
|
||||
},
|
||||
reject() {
|
||||
const { callback } = this.config;
|
||||
this.config.reject();
|
||||
callback(null);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
70
src/components/modals/PandocExportModal.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<modal-inner aria-label="使用Pandoc导出">
|
||||
<div class="modal__content">
|
||||
<p>请为您的<b> pandoc导出</b>选择格式。</p>
|
||||
<form-entry label="Template">
|
||||
<select class="textfield" slot="field" v-model="selectedFormat" @keydown.enter="resolve()">
|
||||
<option value="asciidoc">AsciiDoc</option>
|
||||
<option value="context">ConTeXt</option>
|
||||
<option value="epub">EPUB</option>
|
||||
<option value="epub3">EPUB v3</option>
|
||||
<option value="latex">LaTeX</option>
|
||||
<option value="odt">OpenOffice</option>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="rst">reStructuredText</option>
|
||||
<option value="rtf">Rich Text Format</option>
|
||||
<option value="textile">Textile</option>
|
||||
<option value="docx">Word</option>
|
||||
</select>
|
||||
</form-entry>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="config.reject()">取消</button>
|
||||
<button class="button button--resolve" @click="resolve()">确认</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FileSaver from 'file-saver';
|
||||
import networkSvc from '../../services/networkSvc';
|
||||
import editorSvc from '../../services/editorSvc';
|
||||
import modalTemplate from './common/modalTemplate';
|
||||
import store from '../../store';
|
||||
import badgeSvc from '../../services/badgeSvc';
|
||||
|
||||
export default modalTemplate({
|
||||
computedLocalSettings: {
|
||||
selectedFormat: 'pandocExportFormat',
|
||||
},
|
||||
methods: {
|
||||
async resolve() {
|
||||
this.config.resolve();
|
||||
const currentFile = store.getters['file/current'];
|
||||
const currentContent = store.getters['content/current'];
|
||||
const { selectedFormat } = this;
|
||||
store.dispatch('queue/enqueue', async () => {
|
||||
try {
|
||||
const { body } = await networkSvc.request({
|
||||
method: 'POST',
|
||||
url: 'pandocExport',
|
||||
params: {
|
||||
format: selectedFormat,
|
||||
options: JSON.stringify(store.getters['data/computedSettings'].pandoc),
|
||||
metadata: JSON.stringify(currentContent.properties),
|
||||
},
|
||||
body: JSON.stringify(editorSvc.getPandocAst()),
|
||||
blob: true,
|
||||
timeout: 60000,
|
||||
});
|
||||
FileSaver.saveAs(body, `${currentFile.name}.${selectedFormat}`);
|
||||
badgeSvc.addBadge('exportPandoc');
|
||||
} catch (err) {
|
||||
console.error(err); // eslint-disable-line no-console
|
||||
store.dispatch('notification/error', err);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
67
src/components/modals/PdfExportModal.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<modal-inner aria-label="导出到PDF">
|
||||
<div class="modal__content">
|
||||
<p>请为您的<b> pdf导出</b>选择模板。(该导出很消耗服务器资源,文档太大或图片太多可能会导出超时失败!可参考 <a href="https://gitee.com/mafgwo/stackedit/blob/master/docs/大文档导出PDF方式.md" target="_blank">大文档导出PDF方式</a> 自行导出大文档!)</p>
|
||||
<form-entry label="模板">
|
||||
<select class="textfield" slot="field" v-model="selectedTemplate" @keydown.enter="resolve()">
|
||||
<option v-for="(template, id) in allTemplatesById" :key="id" :value="id">
|
||||
{{ template.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="form-entry__actions">
|
||||
<a href="javascript:void(0)" @click="configureTemplates">配置模板</a>
|
||||
</div>
|
||||
</form-entry>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="config.reject()">取消</button>
|
||||
<button class="button button--resolve" @click="resolve()">确认</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FileSaver from 'file-saver';
|
||||
import exportSvc from '../../services/exportSvc';
|
||||
import networkSvc from '../../services/networkSvc';
|
||||
import modalTemplate from './common/modalTemplate';
|
||||
import store from '../../store';
|
||||
import badgeSvc from '../../services/badgeSvc';
|
||||
|
||||
export default modalTemplate({
|
||||
computedLocalSettings: {
|
||||
selectedTemplate: 'pdfExportTemplate',
|
||||
},
|
||||
methods: {
|
||||
async resolve() {
|
||||
this.config.resolve();
|
||||
const currentFile = store.getters['file/current'];
|
||||
store.dispatch('queue/enqueue', async () => {
|
||||
const html = await exportSvc.applyTemplate(
|
||||
currentFile.id,
|
||||
this.allTemplatesById[this.selectedTemplate],
|
||||
true,
|
||||
);
|
||||
|
||||
try {
|
||||
const { body } = await networkSvc.request({
|
||||
method: 'POST',
|
||||
url: 'pdfExport',
|
||||
params: {
|
||||
options: JSON.stringify(store.getters['data/computedSettings'].wkhtmltopdf),
|
||||
},
|
||||
body: html,
|
||||
blob: true,
|
||||
timeout: 60000,
|
||||
});
|
||||
FileSaver.saveAs(body, `${currentFile.name}.pdf`);
|
||||
badgeSvc.addBadge('exportPdf');
|
||||
} catch (err) {
|
||||
console.error(err); // eslint-disable-line no-console
|
||||
store.dispatch('notification/error', err);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
178
src/components/modals/PublishManagementModal.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<modal-inner class="modal__inner-1--publish-management" aria-label="Manage publication locations">
|
||||
<div class="modal__content">
|
||||
<div class="modal__image">
|
||||
<icon-upload></icon-upload>
|
||||
</div>
|
||||
<p v-if="publishLocations.length"><b>{{currentFileName}}</b> 被发布到了以下位置:</p>
|
||||
<p v-else><b>{{currentFileName}}</b> 还没有被发布.</p>
|
||||
<div>
|
||||
<div class="publish-entry flex flex--column" v-for="location in publishLocations" :key="location.id">
|
||||
<div class="publish-entry__header flex flex--row flex--align-center">
|
||||
<div class="publish-entry__icon flex flex--column flex--center">
|
||||
<icon-provider :provider-id="location.providerId"></icon-provider>
|
||||
</div>
|
||||
<div class="publish-entry__description">
|
||||
{{location.description}}
|
||||
</div>
|
||||
<div class="publish-entry__buttons flex flex--row flex--center">
|
||||
<button class="publish-entry__button button" @click="remove(location)" v-title="'删除位置'">
|
||||
<icon-delete></icon-delete>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="publish-entry__row flex flex--row flex--align-center">
|
||||
<div class="publish-entry__url">
|
||||
{{location.url}}
|
||||
</div>
|
||||
<div class="publish-entry__buttons flex flex--row flex--center" v-if="location.url">
|
||||
<button class="publish-entry__button button" v-clipboard="location.url" @click="info('位置URL已复制到剪贴板!')" v-title="'复制URL'">
|
||||
<icon-content-copy></icon-content-copy>
|
||||
</button>
|
||||
<a class="publish-entry__button button" v-if="location.url" :href="location.url" target="_blank" v-title="'打开位置'">
|
||||
<icon-open-in-new></icon-open-in-new>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="publish-entry__row flex flex--row flex--align-center" v-if="shareUrl(location)">
|
||||
<div class="publish-entry__url">
|
||||
分享链接: {{shareUrl(location)}}
|
||||
</div>
|
||||
<div class="publish-entry__buttons flex flex--row flex--center">
|
||||
<button class="publish-entry__button button" v-clipboard="shareUrl(location)" @click="info('分享URL已复制到剪贴板!')" v-title="'复制分享URL'">
|
||||
<icon-content-copy></icon-content-copy>
|
||||
</button>
|
||||
<a class="publish-entry__button button" :href="shareUrl(location)" target="_blank" v-title="'打开分享'">
|
||||
<icon-open-in-new></icon-open-in-new>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__info" v-if="publishLocations.length">
|
||||
<b>提示:</b> 删除位置不会删除任何文件。
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button button--resolve" @click="config.resolve()">关闭</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters, mapActions } from 'vuex';
|
||||
import ModalInner from './common/ModalInner';
|
||||
import store from '../../store';
|
||||
import badgeSvc from '../../services/badgeSvc';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ModalInner,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('modal', [
|
||||
'config',
|
||||
]),
|
||||
...mapGetters('publishLocation', {
|
||||
publishLocations: 'current',
|
||||
}),
|
||||
currentFileName() {
|
||||
return store.getters['file/current'].name;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions('notification', [
|
||||
'info',
|
||||
]),
|
||||
remove(location) {
|
||||
store.commit('publishLocation/deleteItem', location.id);
|
||||
badgeSvc.addBadge('removePublishLocation');
|
||||
},
|
||||
shareUrl(location) {
|
||||
if (location.providerId !== 'giteegist' && location.providerId !== 'gist') {
|
||||
return null;
|
||||
}
|
||||
if (!location.url || !location.gistId) {
|
||||
return null;
|
||||
}
|
||||
const sharePage = location.providerId === 'gist' ? 'gistshare.html' : 'share.html';
|
||||
return `${window.location.protocol}//${window.location.host}/${sharePage}?id=${location.gistId}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
.publish-entry {
|
||||
margin: 1.5em 0;
|
||||
height: auto;
|
||||
font-size: 17px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
$button-size: 30px;
|
||||
$small-button-size: 22px;
|
||||
|
||||
.publish-entry__header {
|
||||
line-height: $button-size;
|
||||
}
|
||||
|
||||
.publish-entry__row {
|
||||
border-top: 1px solid $hr-color;
|
||||
line-height: $small-button-size;
|
||||
}
|
||||
|
||||
.publish-entry__icon {
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
margin-right: 0.75rem;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.publish-entry__description {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.publish-entry__url {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0.5;
|
||||
font-size: 0.67em;
|
||||
}
|
||||
|
||||
.publish-entry__buttons {
|
||||
margin-left: 0.75rem;
|
||||
|
||||
.publish-entry__row & {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.publish-entry__button {
|
||||
width: $button-size;
|
||||
height: $button-size;
|
||||
padding: 4px;
|
||||
background-color: transparent;
|
||||
opacity: 0.75;
|
||||
|
||||
.publish-entry__row & {
|
||||
width: $small-button-size;
|
||||
height: $small-button-size;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
114
src/components/modals/SettingsModal.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<modal-inner class="modal__inner-1--settings" aria-label="Settings">
|
||||
<div class="modal__content">
|
||||
<div class="tabs flex flex--row">
|
||||
<tab :active="tab === 'custom'" @click="tab = 'custom'">
|
||||
自定义配置
|
||||
</tab>
|
||||
<tab :active="tab === 'default'" @click="tab = 'default'">
|
||||
默认配置
|
||||
</tab>
|
||||
</div>
|
||||
<div class="form-entry" v-if="tab === 'custom'" role="tabpanel" aria-label="自定义配置">
|
||||
<label class="form-entry__label">YAML</label>
|
||||
<div class="form-entry__field form-entry__field--code-editor">
|
||||
<code-editor lang="yaml" :value="customSettings" key="custom-settings" @changed="setCustomSettings"></code-editor>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-entry" v-else-if="tab === 'default'" role="tabpanel" aria-label="默认配置">
|
||||
<label class="form-entry__label">YAML</label>
|
||||
<div class="form-entry__field form-entry__field--code-editor">
|
||||
<code-editor lang="yaml" :value="defaultSettings" key="default-settings" disabled="true"></code-editor>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__error modal__error--settings">{{error}}</div>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="config.reject()">取消</button>
|
||||
<button class="button button--resolve" @click="resolve">确认</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import yaml from 'js-yaml';
|
||||
import { mapGetters } from 'vuex';
|
||||
import ModalInner from './common/ModalInner';
|
||||
import Tab from './common/Tab';
|
||||
import CodeEditor from '../CodeEditor';
|
||||
import defaultSettings from '../../data/defaults/defaultSettings.yml';
|
||||
import store from '../../store';
|
||||
import badgeSvc from '../../services/badgeSvc';
|
||||
|
||||
const emptySettings = '# 增加您的自定义配置覆盖默认配置';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ModalInner,
|
||||
Tab,
|
||||
CodeEditor,
|
||||
},
|
||||
data: () => ({
|
||||
tab: 'custom',
|
||||
defaultSettings,
|
||||
customSettings: null,
|
||||
error: null,
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters('modal', [
|
||||
'config',
|
||||
]),
|
||||
strippedCustomSettings() {
|
||||
return this.customSettings === emptySettings ? '\n' : this.customSettings.replace(/\t/g, ' ');
|
||||
},
|
||||
},
|
||||
created() {
|
||||
const settings = store.getters['data/settings'];
|
||||
this.setCustomSettings(settings === '\n' ? emptySettings : settings);
|
||||
},
|
||||
methods: {
|
||||
setCustomSettings(value) {
|
||||
this.customSettings = value;
|
||||
try {
|
||||
yaml.safeLoad(this.strippedCustomSettings);
|
||||
this.error = null;
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
}
|
||||
},
|
||||
async resolve() {
|
||||
if (!this.error) {
|
||||
const settings = this.strippedCustomSettings;
|
||||
await store.dispatch('data/setSettings', settings);
|
||||
const customSettings = yaml.safeLoad(settings);
|
||||
if (customSettings.shortcuts) {
|
||||
badgeSvc.addBadge('changeShortcuts');
|
||||
}
|
||||
const computedSettings = store.getters['data/computedSettings'];
|
||||
const customSettingsCount = Object
|
||||
.keys(customSettings)
|
||||
.filter(key => key !== 'shortcuts' && computedSettings[key])
|
||||
.length;
|
||||
if (customSettingsCount) {
|
||||
badgeSvc.addBadge('changeSettings');
|
||||
}
|
||||
this.config.resolve(settings);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
.modal__inner-1.modal__inner-1--settings {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.modal__error--settings {
|
||||
white-space: pre-wrap;
|
||||
font-family: $font-family-monospace;
|
||||
font-size: $font-size-monospace;
|
||||
}
|
||||
</style>
|
||||
101
src/components/modals/SponsorModal.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<modal-inner class="modal__inner-1--sponsor" aria-label="Sponsor">
|
||||
<div class="modal__content">
|
||||
<p>Please choose a <b>PayPal</b> option:</p>
|
||||
<a class="paypal-option button flex flex--row flex--center" v-for="button in buttons" :key="button.id" :href="button.link">
|
||||
<div class="flex flex--column">
|
||||
<div>{{button.price}}<div class="paypal-option__offer" v-if="button.offer">{{button.offer}}</div></div>
|
||||
<span>{{button.description}}</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="config.reject()">取消</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import ModalInner from './common/ModalInner';
|
||||
import utils from '../../services/utils';
|
||||
import store from '../../store';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ModalInner,
|
||||
},
|
||||
data() {
|
||||
const sponsorToken = store.getters['workspace/sponsorToken'];
|
||||
const makeButton = (id, price, description, offer) => {
|
||||
const params = {
|
||||
cmd: '_s-xclick',
|
||||
hosted_button_id: id,
|
||||
custom: sponsorToken.sub,
|
||||
};
|
||||
return {
|
||||
id,
|
||||
price,
|
||||
description,
|
||||
offer,
|
||||
link: utils.addQueryParams('https://www.paypal.com/cgi-bin/webscr', params),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
buttons: sponsorToken ? [
|
||||
makeButton('QD7SFZS79D2AL', '$5', '3 months sponsorship'),
|
||||
makeButton('WG64NCFL9TQZJ', '$15', '1 year sponsorship', '-25%'),
|
||||
makeButton('G2E7MN873EQ3U', '$25', '2 years sponsorship', '-37%'),
|
||||
makeButton('JQJT7ARKYC7FC', '$50', '5 years sponsorship', '-50%'),
|
||||
] : [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('modal', [
|
||||
'config',
|
||||
]),
|
||||
},
|
||||
methods: {
|
||||
sponsor() {
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
.modal__inner-1.modal__inner-1--sponsor {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.paypal-option {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
height: auto;
|
||||
font-size: 2.3em;
|
||||
margin: 0.75rem 0;
|
||||
line-height: 1.2;
|
||||
text-transform: none;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.6;
|
||||
white-space: normal;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.paypal-option__offer {
|
||||
float: right;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
padding: 0.1em 0.2em;
|
||||
background-color: darken($error-color, 10);
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
margin-left: -0.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
159
src/components/modals/SyncManagementModal.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<modal-inner class="modal__inner-1--sync-management" aria-label="Manage synchronized locations">
|
||||
<div class="modal__content">
|
||||
<div class="modal__image">
|
||||
<icon-sync></icon-sync>
|
||||
</div>
|
||||
<p v-if="syncLocations.length"><b>{{currentFileName}}</b> 与以下位置同步:</p>
|
||||
<p v-else><b>{{currentFileName}}</b>尚未同步。</p>
|
||||
<div>
|
||||
<div class="sync-entry flex flex--column" v-for="location in syncLocations" :key="location.id">
|
||||
<div class="sync-entry__header flex flex--row flex--align-center">
|
||||
<div class="sync-entry__icon flex flex--column flex--center">
|
||||
<icon-provider :provider-id="location.providerId"></icon-provider>
|
||||
</div>
|
||||
<div class="sync-entry__description">
|
||||
{{location.description}}
|
||||
</div>
|
||||
<div class="sync-entry__buttons flex flex--row flex--center">
|
||||
<button class="sync-entry__button button" @click="remove(location)" v-title="'删除位置'">
|
||||
<icon-delete></icon-delete>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sync-entry__row flex flex--row flex--align-center">
|
||||
<div class="sync-entry__url">
|
||||
{{location.url || 'Gitee app data'}}
|
||||
</div>
|
||||
<div class="sync-entry__buttons flex flex--row flex--center" v-if="location.url">
|
||||
<button class="sync-entry__button button" v-clipboard="location.url" @click="info('位置URL复制到剪贴板!')" v-title="'复制URL'">
|
||||
<icon-content-copy></icon-content-copy>
|
||||
</button>
|
||||
<a class="sync-entry__button button" v-if="location.url" :href="location.url" target="_blank" v-title="'打开位置'">
|
||||
<icon-open-in-new></icon-open-in-new>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__info" v-if="syncLocations.length">
|
||||
<b>提示:</b> 删除位置不会删除任何文件。
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button button--resolve" @click="config.resolve()">关闭</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters, mapActions } from 'vuex';
|
||||
import ModalInner from './common/ModalInner';
|
||||
import store from '../../store';
|
||||
import badgeSvc from '../../services/badgeSvc';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ModalInner,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('modal', [
|
||||
'config',
|
||||
]),
|
||||
...mapGetters('syncLocation', {
|
||||
syncLocations: 'currentWithWorkspaceSyncLocation',
|
||||
}),
|
||||
currentFileName() {
|
||||
return store.getters['file/current'].name;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions('notification', [
|
||||
'info',
|
||||
]),
|
||||
remove(location) {
|
||||
if (location.id === 'main') {
|
||||
this.info('This location can not be removed.');
|
||||
} else {
|
||||
store.commit('syncLocation/deleteItem', location.id);
|
||||
badgeSvc.addBadge('removeSyncLocation');
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
.sync-entry {
|
||||
margin: 1.5em 0;
|
||||
height: auto;
|
||||
font-size: 17px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
$button-size: 30px;
|
||||
$small-button-size: 22px;
|
||||
|
||||
.sync-entry__header {
|
||||
line-height: $button-size;
|
||||
}
|
||||
|
||||
.sync-entry__row {
|
||||
border-top: 1px solid $hr-color;
|
||||
line-height: $small-button-size;
|
||||
}
|
||||
|
||||
.sync-entry__icon {
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
margin-right: 0.75rem;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.sync-entry__description {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sync-entry__url {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0.5;
|
||||
font-size: 0.67em;
|
||||
}
|
||||
|
||||
.sync-entry__buttons {
|
||||
margin-left: 0.75rem;
|
||||
|
||||
.sync-entry__row & {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sync-entry__button {
|
||||
width: $button-size;
|
||||
height: $button-size;
|
||||
padding: 4px;
|
||||
background-color: transparent;
|
||||
opacity: 0.75;
|
||||
|
||||
.sync-entry__row & {
|
||||
width: $small-button-size;
|
||||
height: $small-button-size;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
186
src/components/modals/TemplatesModal.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<modal-inner class="modal__inner-1--templates" aria-label="管理模板">
|
||||
<div class="modal__content">
|
||||
<div class="form-entry">
|
||||
<label class="form-entry__label" for="template">模板</label>
|
||||
<div class="form-entry__field">
|
||||
<input v-if="isEditing" id="template" type="text" class="textfield" v-focus @blur="submitEdit()" @keydown.enter="submitEdit()" @keydown.esc.stop="submitEdit(true)" v-model="editingName">
|
||||
<select v-else id="template" v-model="selectedId" class="textfield">
|
||||
<option v-for="(template, id) in templates" :key="id" :value="id">
|
||||
{{ template.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-entry__actions flex flex--row flex--end">
|
||||
<button class="form-entry__button button" @click="create" v-title="'新建模板'">
|
||||
<icon-file-plus></icon-file-plus>
|
||||
</button>
|
||||
<button class="form-entry__button button" @click="copy" v-title="'复制模板'">
|
||||
<icon-file-multiple></icon-file-multiple>
|
||||
</button>
|
||||
<button v-if="!isReadOnly" class="form-entry__button button" @click="isEditing = true" v-title="'重命名模板'">
|
||||
<icon-pen></icon-pen>
|
||||
</button>
|
||||
<button v-if="!isReadOnly" class="form-entry__button button" @click="remove" v-title="'删除模板'">
|
||||
<icon-delete></icon-delete>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-entry">
|
||||
<label class="form-entry__label">值</label>
|
||||
<div class="form-entry__field" v-for="(template, id) in templates" :key="id" v-if="id === selectedId">
|
||||
<code-editor lang="handlebars" :value="template.value" :disabled="isReadOnly" @changed="template.value = $event"></code-editor>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isReadOnly">
|
||||
<a href="javascript:void(0)" v-if="!showHelpers" @click="showHelpers = true">添加帮助</a>
|
||||
<div class="form-entry" v-else>
|
||||
<br>
|
||||
<label class="form-entry__label">帮助</label>
|
||||
<div class="form-entry__field" v-for="(template, id) in templates" :key="id" v-if="id === selectedId">
|
||||
<code-editor lang="javascript" :value="template.helpers" @changed="template.helpers = $event"></code-editor>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="config.reject()">取消</button>
|
||||
<button class="button button--resolve" @click="resolve()">确认</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import utils from '../../services/utils';
|
||||
import badgeSvc from '../../services/badgeSvc';
|
||||
import ModalInner from './common/ModalInner';
|
||||
import CodeEditor from '../CodeEditor';
|
||||
import emptyTemplateValue from '../../data/empties/emptyTemplateValue.html';
|
||||
import emptyTemplateHelpers from '!raw-loader!../../data/empties/emptyTemplateHelpers.js'; // eslint-disable-line
|
||||
import store from '../../store';
|
||||
|
||||
const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
|
||||
|
||||
function fillEmptyFields(template) {
|
||||
if (template.value === '\n') {
|
||||
template.value = emptyTemplateValue;
|
||||
}
|
||||
if (template.helpers === '\n') {
|
||||
template.helpers = emptyTemplateHelpers;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ModalInner,
|
||||
CodeEditor,
|
||||
},
|
||||
data: () => ({
|
||||
selectedId: '',
|
||||
templates: {},
|
||||
showHelpers: false,
|
||||
isEditing: false,
|
||||
editingName: '',
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters('modal', [
|
||||
'config',
|
||||
]),
|
||||
isReadOnly() {
|
||||
return this.templates[this.selectedId].isAdditional;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.$watch(
|
||||
() => store.getters['data/allTemplatesById'],
|
||||
(allTemplatesById) => {
|
||||
const templates = {};
|
||||
// Sort templates by name
|
||||
Object.entries(allTemplatesById)
|
||||
.sort(([, template1], [, template2]) => collator.compare(template1.name, template2.name))
|
||||
.forEach(([id, template]) => {
|
||||
const templateClone = utils.deepCopy(template);
|
||||
fillEmptyFields(templateClone);
|
||||
templates[id] = templateClone;
|
||||
});
|
||||
this.templates = templates;
|
||||
this.selectedId = this.config.selectedId;
|
||||
if (!templates[this.selectedId]) {
|
||||
[this.selectedId] = Object.keys(templates);
|
||||
}
|
||||
this.isEditing = false;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
this.$watch('selectedId', (selectedId) => {
|
||||
const template = this.templates[selectedId];
|
||||
this.showHelpers = template.helpers !== emptyTemplateHelpers;
|
||||
this.editingName = template.name;
|
||||
}, { immediate: true });
|
||||
},
|
||||
methods: {
|
||||
create() {
|
||||
const template = {
|
||||
name: 'New template',
|
||||
value: '\n',
|
||||
helpers: '\n',
|
||||
};
|
||||
fillEmptyFields(template);
|
||||
this.selectedId = utils.uid();
|
||||
this.templates[this.selectedId] = template;
|
||||
this.isEditing = true;
|
||||
},
|
||||
copy() {
|
||||
const template = utils.deepCopy(this.templates[this.selectedId]);
|
||||
template.name += ' copy';
|
||||
delete template.isAdditional;
|
||||
this.selectedId = utils.uid();
|
||||
this.templates[this.selectedId] = template;
|
||||
this.isEditing = true;
|
||||
},
|
||||
remove() {
|
||||
delete this.templates[this.selectedId];
|
||||
[this.selectedId] = Object.keys(this.templates);
|
||||
},
|
||||
submitEdit(cancel) {
|
||||
const template = this.templates[this.selectedId];
|
||||
if (!cancel && this.editingName) {
|
||||
template.name = utils.sanitizeName(this.editingName);
|
||||
} else {
|
||||
this.editingName = template.name;
|
||||
}
|
||||
setTimeout(() => { // For the form-entry to get the blur event
|
||||
this.isEditing = false;
|
||||
}, 1);
|
||||
},
|
||||
async resolve() {
|
||||
const oldTemplateIds = Object.keys(store.getters['data/templatesById']);
|
||||
await store.dispatch('data/setTemplatesById', this.templates);
|
||||
const newTemplateIds = Object.keys(store.getters['data/templatesById']);
|
||||
const createdCount = newTemplateIds
|
||||
.filter(id => !oldTemplateIds.includes(id))
|
||||
.length;
|
||||
const removedCount = oldTemplateIds
|
||||
.filter(id => !newTemplateIds.includes(id))
|
||||
.length;
|
||||
if (createdCount) {
|
||||
badgeSvc.addBadge('addTemplate');
|
||||
}
|
||||
if (removedCount) {
|
||||
badgeSvc.addBadge('removeTemplate');
|
||||
}
|
||||
this.config.resolve({
|
||||
templates: this.templates,
|
||||
selectedId: this.selectedId,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.modal__inner-1.modal__inner-1--templates {
|
||||
max-width: 600px;
|
||||
}
|
||||
</style>
|
||||
48
src/components/modals/WorkspaceImgPathModal.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<modal-inner aria-label="文档空间图片路径">
|
||||
<div class="modal__content">
|
||||
<div class="modal__image">
|
||||
<icon-provider :provider-id="currentWorkspace.providerId"></icon-provider>
|
||||
</div>
|
||||
<p>在当前文档空间增加图片上传路径。</p>
|
||||
<form-entry label="图片上传路径" error="path">
|
||||
<input slot="field" class="textfield" type="text" placeholder="如:/imgs/{YYYY}-{MM}-{DD}" v-model.trim="path" @keydown.enter="resolve()">
|
||||
<div class="form-entry__info">
|
||||
如果不提供,默认为 /imgs/{YYYY}-{MM}-{DD}。<br/>
|
||||
支持相对路径,如 ./imgs、../imgs 或 imgs 都是相对当前编辑中文档的路径。<br/>
|
||||
变量说明:{YYYY}为年变量、{MM}为月变量、{DD}为日变量、{MDNAME}为当前文档名称
|
||||
</div>
|
||||
</form-entry>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="config.reject()">取消</button>
|
||||
<button class="button button--resolve" @click="resolve()">确认</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import modalTemplate from './common/modalTemplate';
|
||||
|
||||
export default modalTemplate({
|
||||
computedLocalSettings: {
|
||||
path: '',
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('workspace', [
|
||||
'currentWorkspace',
|
||||
]),
|
||||
},
|
||||
methods: {
|
||||
resolve() {
|
||||
if (!this.path) {
|
||||
this.setError('path');
|
||||
}
|
||||
this.config.resolve({
|
||||
path: this.path || '/imgs/{YYYY}-{MM}-{DD}',
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
284
src/components/modals/WorkspaceManagementModal.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<modal-inner class="modal__inner-1--workspace-management" aria-label="管理文档空间">
|
||||
<div class="modal__content">
|
||||
<div class="modal__image">
|
||||
<icon-database></icon-database>
|
||||
</div>
|
||||
<p><br>可以访问以下文档空间:</p>
|
||||
<div class="workspace-entry flex flex--column" v-for="(workspace, id) in workspacesById" :key="id">
|
||||
<div class="flex flex--column">
|
||||
<div class="workspace-entry__header flex flex--row flex--align-center">
|
||||
<div class="workspace-entry__icon">
|
||||
<icon-provider v-if="id === 'main' && !workspace.sub" :provider-id="'stackedit'"></icon-provider>
|
||||
<icon-provider v-else :provider-id="workspace.providerId"></icon-provider>
|
||||
</div>
|
||||
<input class="text-input" type="text" v-if="editedId === id" v-focus @blur="submitEdit()" @keydown.enter="submitEdit()" @keydown.esc.stop="submitEdit(true)" v-model="editingName">
|
||||
<div class="workspace-entry__name" v-else>{{workspace.name}}</div>
|
||||
<div class="workspace-entry__buttons flex flex--row">
|
||||
<button class="workspace-entry__button button" @click="edit(id)" v-title="'编辑名称'">
|
||||
<icon-pen></icon-pen>
|
||||
</button>
|
||||
<template v-if="workspace.providerId === 'giteeAppData' || workspace.providerId === 'githubAppData' || workspace.providerId === 'githubWorkspace'
|
||||
|| workspace.providerId === 'giteeWorkspace' || workspace.providerId === 'gitlabWorkspace' || workspace.providerId === 'giteaWorkspace'">
|
||||
<button class="workspace-entry__button button" @click="stopAutoSync(id)" v-if="workspace.autoSync == undefined || workspace.autoSync" v-title="'关闭自动同步'">
|
||||
<icon-sync-auto></icon-sync-auto>
|
||||
</button>
|
||||
<button class="workspace-entry__button button" @click="startAutoSync(id)" v-if="workspace.autoSync != undefined && !workspace.autoSync" v-title="'启动自动同步'">
|
||||
<icon-sync-stop></icon-sync-stop>
|
||||
</button>
|
||||
</template>
|
||||
<button class="workspace-entry__button button" @click="remove(id)" v-title="'删除'">
|
||||
<icon-delete></icon-delete>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="workspace-entry__row flex flex--row flex--align-center">
|
||||
<div class="workspace-entry__url">
|
||||
{{workspace.url}}
|
||||
</div>
|
||||
<div class="workspace-entry__buttons flex flex--row">
|
||||
<button class="workspace-entry__button button" v-clipboard="workspace.url" @click="info('文档空间URL已复制到剪贴板!')" v-title="'复制URL'">
|
||||
<icon-content-copy></icon-content-copy>
|
||||
</button>
|
||||
<a class="workspace-entry__button button" :href="workspace.url" target="_blank" v-title="'打开文档空间'">
|
||||
<icon-open-in-new></icon-open-in-new>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="workspace-entry__row flex flex--row flex--align-center" v-if="workspace.locationUrl">
|
||||
<div class="workspace-entry__url">
|
||||
{{workspace.locationUrl}}
|
||||
</div>
|
||||
<div class="workspace-entry__buttons flex flex--row">
|
||||
<button class="workspace-entry__button button" v-clipboard="workspace.locationUrl" @click="info('文档空间URL已复制到剪贴板!')" v-title="'复制URL'">
|
||||
<icon-content-copy></icon-content-copy>
|
||||
</button>
|
||||
<a class="workspace-entry__button button" :href="workspace.locationUrl" target="_blank" v-title="'打开文档空间位置'">
|
||||
<icon-open-in-new></icon-open-in-new>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="workspace-entry__offline" v-if="availableOffline[id]">
|
||||
离线可用
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button button--resolve" @click="config.resolve()">关闭</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapGetters, mapActions } from 'vuex';
|
||||
import ModalInner from './common/ModalInner';
|
||||
import workspaceSvc from '../../services/workspaceSvc';
|
||||
import store from '../../store';
|
||||
import badgeSvc from '../../services/badgeSvc';
|
||||
import localDbSvc from '../../services/localDbSvc';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ModalInner,
|
||||
},
|
||||
data: () => ({
|
||||
editedId: null,
|
||||
editingName: '',
|
||||
availableOffline: {},
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters('modal', [
|
||||
'config',
|
||||
]),
|
||||
...mapGetters('workspace', [
|
||||
'workspacesById',
|
||||
'mainWorkspace',
|
||||
'currentWorkspace',
|
||||
]),
|
||||
},
|
||||
methods: {
|
||||
...mapActions('notification', [
|
||||
'info',
|
||||
]),
|
||||
edit(id) {
|
||||
this.editedId = id;
|
||||
this.editingName = this.workspacesById[id].name;
|
||||
},
|
||||
submitEdit(cancel) {
|
||||
const workspace = this.workspacesById[this.editedId];
|
||||
if (workspace) {
|
||||
if (!cancel && this.editingName && this.editingName !== workspace.name) {
|
||||
store.dispatch('workspace/patchWorkspacesById', {
|
||||
[this.editedId]: {
|
||||
...workspace,
|
||||
name: this.editingName,
|
||||
},
|
||||
});
|
||||
badgeSvc.addBadge('renameWorkspace');
|
||||
} else {
|
||||
this.editingName = workspace.name;
|
||||
}
|
||||
}
|
||||
this.editedId = null;
|
||||
},
|
||||
async remove(id) {
|
||||
if (id === this.mainWorkspace.id) {
|
||||
this.info('您的主文档空间无法删除。');
|
||||
} else if (id === this.currentWorkspace.id) {
|
||||
this.info('请先关闭文档空间,然后再将其删除。');
|
||||
} else {
|
||||
try {
|
||||
const workspace = this.workspacesById[id];
|
||||
if (!workspace) {
|
||||
return;
|
||||
}
|
||||
await store.dispatch('modal/open', {
|
||||
type: 'removeWorkspace',
|
||||
name: workspace.name,
|
||||
});
|
||||
workspaceSvc.removeWorkspace(id);
|
||||
badgeSvc.addBadge('removeWorkspace');
|
||||
} catch (e) { /* Cancel */ }
|
||||
}
|
||||
},
|
||||
async stopAutoSync(id) {
|
||||
const workspace = this.workspacesById[id];
|
||||
if (!workspace) {
|
||||
return;
|
||||
}
|
||||
await store.dispatch('modal/open', {
|
||||
type: 'stopAutoSyncWorkspace',
|
||||
name: workspace.name,
|
||||
});
|
||||
store.dispatch('workspace/patchWorkspacesById', {
|
||||
[id]: {
|
||||
...workspace,
|
||||
autoSync: false,
|
||||
},
|
||||
});
|
||||
badgeSvc.addBadge('stopAutoSyncWorkspace');
|
||||
},
|
||||
async startAutoSync(id) {
|
||||
const workspace = this.workspacesById[id];
|
||||
if (!workspace) {
|
||||
return;
|
||||
}
|
||||
await store.dispatch('modal/open', {
|
||||
type: 'autoSyncWorkspace',
|
||||
name: workspace.name,
|
||||
});
|
||||
store.dispatch('workspace/patchWorkspacesById', {
|
||||
[id]: {
|
||||
...workspace,
|
||||
autoSync: true,
|
||||
},
|
||||
});
|
||||
badgeSvc.addBadge('autoSyncWorkspace');
|
||||
},
|
||||
},
|
||||
created() {
|
||||
Object.keys(this.workspacesById).forEach(async (workspaceId) => {
|
||||
const cancel = localDbSvc.getWorkspaceItems(workspaceId, () => {
|
||||
Vue.set(this.availableOffline, workspaceId, true);
|
||||
cancel();
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
.workspace-entry {
|
||||
margin: 1.75em 0;
|
||||
height: auto;
|
||||
font-size: 17px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
$button-size: 30px;
|
||||
$small-button-size: 22px;
|
||||
|
||||
.workspace-entry__header {
|
||||
line-height: $button-size;
|
||||
|
||||
.text-input {
|
||||
border: 1px solid $link-color;
|
||||
padding: 0 5px;
|
||||
line-height: $button-size;
|
||||
height: $button-size;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-entry__row {
|
||||
border-top: 1px solid $hr-color;
|
||||
line-height: $small-button-size;
|
||||
}
|
||||
|
||||
.workspace-entry__icon {
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
margin-right: 0.75rem;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.workspace-entry__name {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.workspace-entry__url {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0.5;
|
||||
font-size: 0.67em;
|
||||
}
|
||||
|
||||
.workspace-entry__buttons {
|
||||
margin-left: 0.75rem;
|
||||
|
||||
.workspace-entry__row & {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-entry__button {
|
||||
width: $button-size;
|
||||
height: $button-size;
|
||||
padding: 4px;
|
||||
background-color: transparent;
|
||||
opacity: 0.75;
|
||||
|
||||
.workspace-entry__row & {
|
||||
width: $small-button-size;
|
||||
height: $small-button-size;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-entry__offline {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
padding: 0.15em 0.35em;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
background-color: darken($error-color, 10);
|
||||
}
|
||||
</style>
|
||||
23
src/components/modals/common/FormEntry.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="form-entry" :error="error">
|
||||
<label class="form-entry__label" :for="uid">{{label}}<span class="form-entry__label-info" v-if="info"> — {{info}}</span></label>
|
||||
<div class="form-entry__field">
|
||||
<slot name="field"></slot>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import utils from '../../../services/utils';
|
||||
|
||||
export default {
|
||||
props: ['label', 'info', 'error'],
|
||||
data: () => ({
|
||||
uid: utils.uid(),
|
||||
}),
|
||||
mounted() {
|
||||
this.$el.querySelector('input,select,textarea').id = this.uid;
|
||||
},
|
||||
};
|
||||
</script>
|
||||
42
src/components/modals/common/ModalInner.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="modal__inner-1" role="dialog">
|
||||
<div class="modal__inner-2">
|
||||
<button class="modal__close-button button not-tabbable" @click="config.reject()" v-title="'关闭窗口'">
|
||||
<icon-close></icon-close>
|
||||
</button>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
...mapGetters('modal', [
|
||||
'config',
|
||||
]),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
.modal__close-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 2px;
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: rgba(0, 0, 0, 0.67);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
13
src/components/modals/common/Tab.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="tabs__tab flex flex--row" :class="{'tabs__tab--active': active}" role="tab">
|
||||
<a class="flex flex--column flex--center" href="javascript:void(0)" @click="$emit('click')">
|
||||
<slot></slot>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['active'],
|
||||
};
|
||||
</script>
|
||||
79
src/components/modals/common/modalTemplate.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import ModalInner from './ModalInner';
|
||||
import FormEntry from './FormEntry';
|
||||
import store from '../../../store';
|
||||
|
||||
const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
|
||||
|
||||
export default (desc) => {
|
||||
const component = {
|
||||
...desc,
|
||||
data: () => ({
|
||||
...desc.data ? desc.data() : {},
|
||||
errorTimeouts: {},
|
||||
}),
|
||||
components: {
|
||||
...desc.components || {},
|
||||
ModalInner,
|
||||
FormEntry,
|
||||
},
|
||||
computed: {
|
||||
...desc.computed || {},
|
||||
config() {
|
||||
return store.getters['modal/config'];
|
||||
},
|
||||
currentFileName() {
|
||||
return store.getters['file/current'].name;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...desc.methods || {},
|
||||
openFileProperties: () => store.dispatch('modal/open', 'fileProperties'),
|
||||
setError(name) {
|
||||
clearTimeout(this.errorTimeouts[name]);
|
||||
const formEntry = this.$el.querySelector(`.form-entry[error=${name}]`);
|
||||
if (formEntry) {
|
||||
formEntry.classList.add('form-entry--error');
|
||||
this.errorTimeouts[name] = setTimeout(() => {
|
||||
formEntry.classList.remove('form-entry--error');
|
||||
}, 1000);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
Object.entries(desc.computedLocalSettings || {}).forEach(([key, id]) => {
|
||||
component.computed[key] = {
|
||||
get() {
|
||||
return store.getters['data/localSettings'][id];
|
||||
},
|
||||
set(value) {
|
||||
store.dispatch('data/patchLocalSettings', {
|
||||
[id]: value,
|
||||
});
|
||||
},
|
||||
};
|
||||
if (key === 'selectedTemplate') {
|
||||
component.computed.allTemplatesById = () => {
|
||||
const allTemplatesById = store.getters['data/allTemplatesById'];
|
||||
const sortedTemplatesById = {};
|
||||
Object.entries(allTemplatesById)
|
||||
.sort(([, template1], [, template2]) => collator.compare(template1.name, template2.name))
|
||||
.forEach(([templateId, template]) => {
|
||||
sortedTemplatesById[templateId] = template;
|
||||
});
|
||||
return sortedTemplatesById;
|
||||
};
|
||||
// Make use of `function` to have `this` bound to the component
|
||||
component.methods.configureTemplates = async function () { // eslint-disable-line func-names
|
||||
const { selectedId } = await store.dispatch('modal/open', {
|
||||
type: 'templates',
|
||||
selectedId: this.selectedTemplate,
|
||||
});
|
||||
store.dispatch('data/patchLocalSettings', {
|
||||
[id]: selectedId,
|
||||
});
|
||||
};
|
||||
}
|
||||
});
|
||||
component.computedLocalSettings = null;
|
||||
return component;
|
||||
};
|
||||
67
src/components/modals/providers/BloggerPagePublishModal.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<modal-inner aria-label="发布到Blogger Page">
|
||||
<div class="modal__content">
|
||||
<div class="modal__image">
|
||||
<icon-provider provider-id="bloggerPage"></icon-provider>
|
||||
</div>
|
||||
<p>发布<b> {{CurrentFileName}} </b>到您的<b> Blogger页面</b>。</p>
|
||||
<form-entry label="Blog URL" error="blogUrl">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="blogUrl" @keydown.enter="resolve()">
|
||||
<div class="form-entry__info">
|
||||
<b>例如:</b> http://example.blogger.com/
|
||||
</div>
|
||||
</form-entry>
|
||||
<form-entry label="现有页面ID" info="可选的">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="pageId" @keydown.enter="resolve()">
|
||||
</form-entry>
|
||||
<form-entry label="模板">
|
||||
<select slot="field" class="textfield" v-model="selectedTemplate" @keydown.enter="resolve()">
|
||||
<option v-for="(template, id) in allTemplatesById" :key="id" :value="id">
|
||||
{{ template.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="form-entry__actions">
|
||||
<a href="javascript:void(0)" @click="configureTemplates">配置模板</a>
|
||||
</div>
|
||||
</form-entry>
|
||||
<div class="modal__info">
|
||||
<b>提示:</b> 您可以在<a href="javascript:void(0)" @click="openFileProperties">文件属性</a>中提供<code>title</code>的值
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="config.reject()">取消</button>
|
||||
<button class="button button--resolve" @click="resolve()">确认</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import bloggerPageProvider from '../../../services/providers/bloggerPageProvider';
|
||||
import modalTemplate from '../common/modalTemplate';
|
||||
|
||||
export default modalTemplate({
|
||||
data: () => ({
|
||||
pageId: '',
|
||||
}),
|
||||
computedLocalSettings: {
|
||||
blogUrl: 'bloggerBlogUrl',
|
||||
selectedTemplate: 'bloggerPublishTemplate',
|
||||
},
|
||||
methods: {
|
||||
resolve() {
|
||||
if (!this.blogUrl) {
|
||||
this.setError('blogUrl');
|
||||
} else {
|
||||
// Return new location
|
||||
const location = bloggerPageProvider.makeLocation(
|
||||
this.config.token,
|
||||
this.blogUrl,
|
||||
this.pageId,
|
||||
);
|
||||
location.templateId = this.selectedTemplate;
|
||||
this.config.resolve(location);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
68
src/components/modals/providers/BloggerPublishModal.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<modal-inner aria-label="发布到Blogger">
|
||||
<div class="modal__content">
|
||||
<div class="modal__image">
|
||||
<icon-provider provider-id="blogger"></icon-provider>
|
||||
</div>
|
||||
<p>向您的<b> Blogger </b>网站发布<b> {{CurrentFileName}} </b>。</p>
|
||||
<form-entry label="Blog URL" error="blogUrl">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="blogUrl" @keydown.enter="resolve()">
|
||||
<div class="form-entry__info">
|
||||
<b>例如:</b> http://example.blogger.com/
|
||||
</div>
|
||||
</form-entry>
|
||||
<form-entry label="现有的帖子ID" info="可选的">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="postId" @keydown.enter="resolve()">
|
||||
</form-entry>
|
||||
<form-entry label="模板">
|
||||
<select slot="field" class="textfield" v-model="selectedTemplate" @keydown.enter="resolve()">
|
||||
<option v-for="(template, id) in allTemplatesById" :key="id" :value="id">
|
||||
{{ template.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="form-entry__actions">
|
||||
<a href="javascript:void(0)" @click="configureTemplates">配置模板</a>
|
||||
</div>
|
||||
</form-entry>
|
||||
<div class="modal__info">
|
||||
<b>提示:</b> 在<a href="javascript:void(0)" @click="openFileProperties">文件中</a>中您可以为 <code>title</code>, <code>tags</code>,
|
||||
<code>status</code> 和 <code>date</code>提供值。
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="config.reject()">取消</button>
|
||||
<button class="button button--resolve" @click="resolve()">确认</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import bloggerProvider from '../../../services/providers/bloggerProvider';
|
||||
import modalTemplate from '../common/modalTemplate';
|
||||
|
||||
export default modalTemplate({
|
||||
data: () => ({
|
||||
postId: '',
|
||||
}),
|
||||
computedLocalSettings: {
|
||||
blogUrl: 'bloggerBlogUrl',
|
||||
selectedTemplate: 'bloggerPublishTemplate',
|
||||
},
|
||||
methods: {
|
||||
resolve() {
|
||||
if (!this.blogUrl) {
|
||||
this.setError('blogUrl');
|
||||
} else {
|
||||
// Return new location
|
||||
const location = bloggerProvider.makeLocation(
|
||||
this.config.token,
|
||||
this.blogUrl,
|
||||
this.postId,
|
||||
);
|
||||
location.templateId = this.selectedTemplate;
|
||||
this.config.resolve(location);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
55
src/components/modals/providers/CouchdbCredentialsModal.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<modal-inner aria-label="插入图片">
|
||||
<div class="modal__content">
|
||||
<div class="modal__image">
|
||||
<icon-provider provider-id="couchdb"></icon-provider>
|
||||
</div>
|
||||
<p>请提供您的凭据,以登录<b>CouchDB</b>。</p>
|
||||
<form-entry label="用户名" error="name">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="name" @keydown.enter="resolve()">
|
||||
</form-entry>
|
||||
<form-entry label="密码" error="password">
|
||||
<input slot="field" class="textfield" type="password" v-model.trim="password" @keydown.enter="resolve()">
|
||||
</form-entry>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="config.reject()">取消</button>
|
||||
<button class="button button--resolve" @click="resolve()">确认</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import modalTemplate from '../common/modalTemplate';
|
||||
import store from '../../../store';
|
||||
|
||||
export default modalTemplate({
|
||||
data: () => ({
|
||||
name: '',
|
||||
password: '',
|
||||
}),
|
||||
created() {
|
||||
this.name = this.config.token.name;
|
||||
this.password = this.config.token.password;
|
||||
},
|
||||
methods: {
|
||||
resolve() {
|
||||
if (!this.name) {
|
||||
this.setError('name');
|
||||
}
|
||||
if (!this.password) {
|
||||
this.setError('password');
|
||||
}
|
||||
if (this.name && this.password) {
|
||||
const token = {
|
||||
...this.config.token,
|
||||
name: this.name,
|
||||
password: this.password,
|
||||
};
|
||||
store.dispatch('data/addCouchdbToken', token);
|
||||
this.config.resolve();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
55
src/components/modals/providers/CouchdbWorkspaceModal.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<modal-inner aria-label="增加CouchDB文档空间">
|
||||
<div class="modal__content">
|
||||
<div class="modal__image">
|
||||
<icon-provider provider-id="couchdb"></icon-provider>
|
||||
</div>
|
||||
<p>创建一个与<b>CouchDB</b>数据库同步的文档空间。</p>
|
||||
<form-entry label="Database URL" error="dbUrl">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="dbUrl" @keydown.enter="resolve()">
|
||||
<div class="form-entry__info">
|
||||
<b>例如:</b> https://instance.smileupps.com/stackedit-workspace
|
||||
</div>
|
||||
<div class="form-entry__actions">
|
||||
<!-- https://community.stackedit.io/t/couchdb-workspace-setup/ -->
|
||||
<a href="#" target="_blank">如何设置?</a>
|
||||
</div>
|
||||
</form-entry>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="config.reject()">取消</button>
|
||||
<button class="button button--resolve" @click="resolve()">确认</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import modalTemplate from '../common/modalTemplate';
|
||||
import utils from '../../../services/utils';
|
||||
|
||||
export default modalTemplate({
|
||||
data: () => ({
|
||||
dbUrl: '',
|
||||
}),
|
||||
methods: {
|
||||
resolve() {
|
||||
if (!this.dbUrl) {
|
||||
this.setError('dbUrl');
|
||||
} else {
|
||||
const url = utils.addQueryParams('app', {
|
||||
providerId: 'couchdbWorkspace',
|
||||
dbUrl: this.dbUrl,
|
||||
}, true);
|
||||
this.config.resolve();
|
||||
window.open(url);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.couchdb-workspace__info {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
</style>
|
||||
109
src/components/modals/providers/CustomAccountModal.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<modal-inner aria-label="链接自定义图床账号">
|
||||
<div class="modal__content">
|
||||
<div class="modal__image">
|
||||
<icon-provider provider-id="custom"></icon-provider>
|
||||
</div>
|
||||
<p>将您的<b>自定义图床</b>账号链接到<b>StackEdit中文版</b>。</p>
|
||||
<form-entry label="自定义标识" error="name">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="name" @keydown.enter="resolve()">
|
||||
<div class="form-entry__info">
|
||||
自定义标识如果一样会覆盖之前的自定义图床账号。
|
||||
</div>
|
||||
</form-entry>
|
||||
<form-entry label="上传图片接口地址" error="uploadUrl">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="uploadUrl" @keydown.enter="resolve()">
|
||||
<div class="form-entry__info">
|
||||
填入您个人的图床上传接口地址,上传接口仅支持POST提交。
|
||||
</div>
|
||||
</form-entry>
|
||||
<form-entry label="文件参数名" error="fileParamName">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="fileParamName" @keydown.enter="resolve()">
|
||||
<div class="form-entry__info">
|
||||
文件参数名如:file
|
||||
</div>
|
||||
</form-entry>
|
||||
<form-entry label="自定义请求头配置" error="customHeaders">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="customHeaders" @keydown.enter="resolve()">
|
||||
<div class="form-entry__info">
|
||||
非必填,自定义请求头是JSON字符串格式,如:{"token": "..."}
|
||||
</div>
|
||||
</form-entry>
|
||||
<form-entry label="自定义FORM参数设置" error="customParams">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="customParams" @keydown.enter="resolve()">
|
||||
<div class="form-entry__info">
|
||||
非必填,自定义FORM参数是JSON字符串格式,如:{"param1": "..."}
|
||||
</div>
|
||||
</form-entry>
|
||||
<form-entry label="响应图片URL参数" error="resultUrlParam">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="resultUrlParam" @keydown.enter="resolve()">
|
||||
<div class="form-entry__info">
|
||||
响应JSON中图片URL的路径,如 data.url
|
||||
</div>
|
||||
</form-entry>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="config.reject()">取消</button>
|
||||
<button class="button button--resolve" @click="resolve()">确认</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import modalTemplate from '../common/modalTemplate';
|
||||
|
||||
export default modalTemplate({
|
||||
computedLocalSettings: {
|
||||
name: 'name',
|
||||
uploadUrl: 'uploadUrl',
|
||||
fileParamName: 'fileParamName',
|
||||
customHeaders: 'customHeaders',
|
||||
customParams: 'customParams',
|
||||
resultUrlParam: 'resultUrlParam',
|
||||
},
|
||||
methods: {
|
||||
resolve() {
|
||||
if (!this.name) {
|
||||
this.setError('name');
|
||||
}
|
||||
if (!this.uploadUrl) {
|
||||
this.setError('uploadUrl');
|
||||
}
|
||||
if (!this.fileParamName) {
|
||||
this.setError('fileParamName');
|
||||
}
|
||||
if (!this.resultUrlParam) {
|
||||
this.setError('resultUrlParam');
|
||||
}
|
||||
let customHeaders = null;
|
||||
if (this.customHeaders) {
|
||||
try {
|
||||
customHeaders = JSON.parse(this.customHeaders);
|
||||
} catch (err) {
|
||||
this.setError('customHeaders');
|
||||
return;
|
||||
}
|
||||
}
|
||||
let customParams = null;
|
||||
if (this.customParams) {
|
||||
try {
|
||||
customParams = JSON.parse(this.customParams);
|
||||
} catch (err) {
|
||||
this.setError('customParams');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.uploadUrl && this.fileParamName) {
|
||||
this.config.resolve({
|
||||
name: this.name,
|
||||
uploadUrl: this.uploadUrl,
|
||||
fileParamName: this.fileParamName,
|
||||
resultUrlParam: this.resultUrlParam,
|
||||
customHeaders,
|
||||
customParams,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
34
src/components/modals/providers/DropboxAccountModal.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<modal-inner aria-label="链接Dropbox账号">
|
||||
<div class="modal__content">
|
||||
<div class="modal__image">
|
||||
<icon-provider provider-id="dropbox"></icon-provider>
|
||||
</div>
|
||||
<p>将您的<b>Dropbox</b>链接到<b>StackEdit中文版</b>。</p>
|
||||
<div class="form-entry">
|
||||
<div class="form-entry__checkbox">
|
||||
<label>
|
||||
<input type="checkbox" v-model="restrictedAccess"> 限制访问
|
||||
</label>
|
||||
<div class="form-entry__info">
|
||||
如果限制,访问将仅限于<b>/Applications/StackEdit (restricted)</b>文件夹。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="config.reject()">取消</button>
|
||||
<button class="button button--resolve" @click="config.resolve()">确认</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import modalTemplate from '../common/modalTemplate';
|
||||
|
||||
export default modalTemplate({
|
||||
computedLocalSettings: {
|
||||
restrictedAccess: 'dropboxRestrictedAccess',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
60
src/components/modals/providers/DropboxPublishModal.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<modal-inner aria-label="发布到Dropbox">
|
||||
<div class="modal__content">
|
||||
<div class="modal__image">
|
||||
<icon-provider provider-id="dropbox"></icon-provider>
|
||||
</div>
|
||||
<p>发布到您的<b>Dropbox</b>。</p>
|
||||
<form-entry label="File path" error="path">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()">
|
||||
<div class="form-entry__info">
|
||||
<b>例如:</b> {{config.token.fullAccess ? '' : '/Applications/StackEdit (restricted)'}}/path/to/My Document.html<br>
|
||||
如果文件存在,将被覆盖。
|
||||
</div>
|
||||
</form-entry>
|
||||
<form-entry label="Template">
|
||||
<select slot="field" class="textfield" v-model="selectedTemplate" @keydown.enter="resolve()">
|
||||
<option v-for="(template, id) in allTemplatesById" :key="id" :value="id">
|
||||
{{ template.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="form-entry__actions">
|
||||
<a href="javascript:void(0)" @click="configureTemplates">配置模板</a>
|
||||
</div>
|
||||
</form-entry>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="config.reject()">取消</button>
|
||||
<button class="button button--resolve" @click="resolve()">确认</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import dropboxProvider from '../../../services/providers/dropboxProvider';
|
||||
import modalTemplate from '../common/modalTemplate';
|
||||
|
||||
export default modalTemplate({
|
||||
data: () => ({
|
||||
path: '',
|
||||
}),
|
||||
computedLocalSettings: {
|
||||
selectedTemplate: 'dropboxPublishTemplate',
|
||||
},
|
||||
created() {
|
||||
this.path = `/${this.currentFileName}.html`;
|
||||
},
|
||||
methods: {
|
||||
resolve() {
|
||||
if (!dropboxProvider.checkPath(this.path)) {
|
||||
this.setError('path');
|
||||
} else {
|
||||
// Return new location
|
||||
const location = dropboxProvider.makeLocation(this.config.token, this.path);
|
||||
location.templateId = this.selectedTemplate;
|
||||
this.config.resolve(location);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
46
src/components/modals/providers/DropboxSaveModal.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<modal-inner aria-label="与 Dropbox 同步">
|
||||
<div class="modal__content">
|
||||
<div class="modal__image">
|
||||
<icon-provider provider-id="dropbox"></icon-provider>
|
||||
</div>
|
||||
<p>将<b> {{CurrentFileName}} </b>保存到您的<b>Dropbox</b>并保持同步。</p>
|
||||
<form-entry label="File path" error="path">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()">
|
||||
<div class="form-entry__info">
|
||||
<b>例如:</b> {{config.token.fullAccess ? '' : '/Applications/StackEdit (restricted)'}}/path/to/My Document.md<br>
|
||||
如果文件存在,将被覆盖。
|
||||
</div>
|
||||
</form-entry>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="config.reject()">取消</button>
|
||||
<button class="button button--resolve" @click="resolve()">确认</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import dropboxProvider from '../../../services/providers/dropboxProvider';
|
||||
import modalTemplate from '../common/modalTemplate';
|
||||
|
||||
export default modalTemplate({
|
||||
data: () => ({
|
||||
path: '',
|
||||
}),
|
||||
created() {
|
||||
this.path = `/${this.currentFileName}.md`;
|
||||
},
|
||||
methods: {
|
||||
resolve() {
|
||||
if (!dropboxProvider.checkPath(this.path)) {
|
||||
this.setError('path');
|
||||
} else {
|
||||
// Return new location
|
||||
const location = dropboxProvider.makeLocation(this.config.token, this.path);
|
||||
this.config.resolve(location);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
79
src/components/modals/providers/GistPublishModal.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<modal-inner aria-label="发布到GitHubGist">
|
||||
<div class="modal__content">
|
||||
<div class="modal__image">
|
||||
<icon-provider provider-id="gist"></icon-provider>
|
||||
</div>
|
||||
<p>发布<b> {{CurrentFileName}} </b>到<b>GitHubGist</b>。</p>
|
||||
<form-entry label="文件名" error="filename">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="filename" @keydown.enter="resolve()">
|
||||
</form-entry>
|
||||
<div class="form-entry">
|
||||
<div class="form-entry__checkbox">
|
||||
<label>
|
||||
<input type="checkbox" v-model="isPublic"> 公开的
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<form-entry label="存在Gist ID" info="可选的">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="gistId" @keydown.enter="resolve()">
|
||||
<div class="form-entry__info">
|
||||
如果文件存在于GitHubGist中,则将被覆盖。
|
||||
</div>
|
||||
</form-entry>
|
||||
<form-entry label="Template">
|
||||
<select slot="field" class="textfield" v-model="selectedTemplate" @keydown.enter="resolve()">
|
||||
<option v-for="(template, id) in allTemplatesById" :key="id" :value="id">
|
||||
{{ template.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="form-entry__actions">
|
||||
<a href="javascript:void(0)" @click="configureTemplates">配置模板</a>
|
||||
</div>
|
||||
</form-entry>
|
||||
<div class="modal__info">
|
||||
<b>ProTip:</b> You can provide a value for <code>title</code> in the <a href="javascript:void(0)" @click="openFileProperties">file properties</a>.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="config.reject()">取消</button>
|
||||
<button class="button button--resolve" @click="resolve()">确认</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import gistProvider from '../../../services/providers/gistProvider';
|
||||
import modalTemplate from '../common/modalTemplate';
|
||||
|
||||
export default modalTemplate({
|
||||
data: () => ({
|
||||
filename: '',
|
||||
gistId: '',
|
||||
}),
|
||||
computedLocalSettings: {
|
||||
isPublic: 'gistIsPublic',
|
||||
selectedTemplate: 'gistPublishTemplate',
|
||||
},
|
||||
created() {
|
||||
this.filename = `${this.currentFileName}.md`;
|
||||
},
|
||||
methods: {
|
||||
resolve() {
|
||||
if (!this.filename) {
|
||||
this.setError('filename');
|
||||
} else {
|
||||
// Return new location
|
||||
const location = gistProvider.makeLocation(
|
||||
this.config.token,
|
||||
this.filename,
|
||||
this.isPublic,
|
||||
this.gistId,
|
||||
);
|
||||
location.templateId = this.selectedTemplate;
|
||||
this.config.resolve(location);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
64
src/components/modals/providers/GistSyncModal.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<modal-inner aria-label="与 GitHubGist 同步">
|
||||
<div class="modal__content">
|
||||
<div class="modal__image">
|
||||
<icon-provider provider-id="gist"></icon-provider>
|
||||
</div>
|
||||
<p>将<b> {{currentFileName}} </b>保存到<b>GitHubGist</b>并保持同步。</p>
|
||||
<form-entry label="文件名" error="filename">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="filename" @keydown.enter="resolve()">
|
||||
</form-entry>
|
||||
<div class="form-entry">
|
||||
<div class="form-entry__checkbox">
|
||||
<label>
|
||||
<input type="checkbox" v-model="isPublic"> 公开的
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<form-entry label="存在Gist ID" info="可选的">
|
||||
<input slot="field" class="textfield" type="text" v-model.trim="gistId" @keydown.enter="resolve()">
|
||||
<div class="form-entry__info">
|
||||
如果文件存在于GitHubGist中,则将被覆盖。
|
||||
</div>
|
||||
</form-entry>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="config.reject()">取消</button>
|
||||
<button class="button button--resolve" @click="resolve()">确认</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import gistProvider from '../../../services/providers/gistProvider';
|
||||
import modalTemplate from '../common/modalTemplate';
|
||||
|
||||
export default modalTemplate({
|
||||
data: () => ({
|
||||
filename: '',
|
||||
gistId: '',
|
||||
}),
|
||||
computedLocalSettings: {
|
||||
isPublic: 'gistIsPublic',
|
||||
},
|
||||
created() {
|
||||
this.filename = `${this.currentFileName}.md`;
|
||||
},
|
||||
methods: {
|
||||
resolve() {
|
||||
if (!this.filename) {
|
||||
this.setError('filename');
|
||||
} else {
|
||||
// Return new location
|
||||
const location = gistProvider.makeLocation(
|
||||
this.config.token,
|
||||
this.filename,
|
||||
this.isPublic,
|
||||
this.gistId,
|
||||
);
|
||||
this.config.resolve(location);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||