first commit

This commit is contained in:
Jane
2024-02-19 17:25:32 +08:00
commit d21f90672b
455 changed files with 67178 additions and 0 deletions

BIN
src/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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

View 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

View 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

View 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
View 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
View 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

View 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

View 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
View 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
View 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

View 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

View 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
View 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

View 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

View 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

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.4 KiB

109
src/components/App.vue Normal file
View 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>

View 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>

View 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>

View 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
View 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})]`, `![输入图片说明](${url})`);
} 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>

View 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
View 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>

View 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>

View 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
View 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
View 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>

View 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>

View 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
View 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>

View 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
View 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>

View 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>

View 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
View 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
View 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>

View 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>

View 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>

View 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>

View 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;
}
}

View 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;
}
}

View 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));

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>转换为PDFWordEPUB...</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(/&#160;/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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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">&mdash; {{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">&mdash; {{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>

View 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>

View 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>

View 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">将信息添加到您的发布WordPressBlogger ...</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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"> &mdash; {{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>

View 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>

View 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>

View 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;
};

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

Some files were not shown because too many files have changed in this diff Show More