Repository: benweet/stackedit Branch: master Commit: 6dce2a5e36b7 Files: 330 Total size: 1005.7 KB Directory structure: gitextract_i7x6xpbj/ ├── .babelrc ├── .dockerignore ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .postcssrc.js ├── .stylelintrc ├── .travis.yml ├── Dockerfile ├── LICENSE ├── README.md ├── build/ │ ├── build.js │ ├── check-versions.js │ ├── deploy.sh │ ├── dev-client.js │ ├── dev-server.js │ ├── utils.js │ ├── vue-loader.conf.js │ ├── webpack.base.conf.js │ ├── webpack.dev.conf.js │ ├── webpack.prod.conf.js │ └── webpack.style.conf.js ├── chart/ │ ├── .helmignore │ ├── Chart.yaml │ ├── templates/ │ │ ├── NOTES.txt │ │ ├── _helpers.tpl │ │ ├── deployment.yaml │ │ ├── ingress.yaml │ │ ├── service.yaml │ │ └── tests/ │ │ └── test-connection.yaml │ └── values.yaml ├── chrome-app/ │ └── manifest.json ├── config/ │ ├── dev.env.js │ ├── index.js │ └── prod.env.js ├── gulpfile.js ├── index.html ├── index.js ├── package.json ├── server/ │ ├── conf.js │ ├── github.js │ ├── index.js │ ├── pandoc.js │ ├── pdf.js │ └── user.js ├── src/ │ ├── components/ │ │ ├── App.vue │ │ ├── ButtonBar.vue │ │ ├── CodeEditor.vue │ │ ├── ContextMenu.vue │ │ ├── Editor.vue │ │ ├── Explorer.vue │ │ ├── ExplorerNode.vue │ │ ├── FindReplace.vue │ │ ├── Layout.vue │ │ ├── Modal.vue │ │ ├── NavigationBar.vue │ │ ├── Notification.vue │ │ ├── Preview.vue │ │ ├── SideBar.vue │ │ ├── SplashScreen.vue │ │ ├── StatusBar.vue │ │ ├── Toc.vue │ │ ├── Tour.vue │ │ ├── UserImage.vue │ │ ├── UserName.vue │ │ ├── common/ │ │ │ ├── EditorClassApplier.js │ │ │ ├── PreviewClassApplier.js │ │ │ └── vueGlobals.js │ │ ├── gutters/ │ │ │ ├── Comment.vue │ │ │ ├── CommentList.vue │ │ │ ├── CurrentDiscussion.vue │ │ │ ├── EditorNewDiscussionButton.vue │ │ │ ├── NewComment.vue │ │ │ ├── PreviewNewDiscussionButton.vue │ │ │ └── StickyComment.vue │ │ ├── menus/ │ │ │ ├── HistoryMenu.vue │ │ │ ├── ImportExportMenu.vue │ │ │ ├── MainMenu.vue │ │ │ ├── PublishMenu.vue │ │ │ ├── SyncMenu.vue │ │ │ ├── WorkspaceBackupMenu.vue │ │ │ ├── WorkspacesMenu.vue │ │ │ └── common/ │ │ │ └── MenuEntry.vue │ │ └── modals/ │ │ ├── AboutModal.vue │ │ ├── AccountManagementModal.vue │ │ ├── BadgeManagementModal.vue │ │ ├── FilePropertiesModal.vue │ │ ├── HtmlExportModal.vue │ │ ├── ImageModal.vue │ │ ├── LinkModal.vue │ │ ├── PandocExportModal.vue │ │ ├── PdfExportModal.vue │ │ ├── PublishManagementModal.vue │ │ ├── SettingsModal.vue │ │ ├── SponsorModal.vue │ │ ├── SyncManagementModal.vue │ │ ├── TemplatesModal.vue │ │ ├── WorkspaceManagementModal.vue │ │ ├── common/ │ │ │ ├── FormEntry.vue │ │ │ ├── ModalInner.vue │ │ │ ├── Tab.vue │ │ │ └── modalTemplate.js │ │ └── providers/ │ │ ├── BloggerPagePublishModal.vue │ │ ├── BloggerPublishModal.vue │ │ ├── CouchdbCredentialsModal.vue │ │ ├── CouchdbWorkspaceModal.vue │ │ ├── DropboxAccountModal.vue │ │ ├── DropboxPublishModal.vue │ │ ├── DropboxSaveModal.vue │ │ ├── GistPublishModal.vue │ │ ├── GistSyncModal.vue │ │ ├── GithubAccountModal.vue │ │ ├── GithubOpenModal.vue │ │ ├── GithubPublishModal.vue │ │ ├── GithubSaveModal.vue │ │ ├── GithubWorkspaceModal.vue │ │ ├── GitlabAccountModal.vue │ │ ├── GitlabOpenModal.vue │ │ ├── GitlabPublishModal.vue │ │ ├── GitlabSaveModal.vue │ │ ├── GitlabWorkspaceModal.vue │ │ ├── GoogleDriveAccountModal.vue │ │ ├── GoogleDrivePublishModal.vue │ │ ├── GoogleDriveSaveModal.vue │ │ ├── GoogleDriveWorkspaceModal.vue │ │ ├── GooglePhotoModal.vue │ │ ├── WordpressPublishModal.vue │ │ ├── ZendeskAccountModal.vue │ │ └── ZendeskPublishModal.vue │ ├── data/ │ │ ├── constants.js │ │ ├── defaults/ │ │ │ ├── defaultLayoutSettings.js │ │ │ ├── defaultLocalSettings.js │ │ │ ├── defaultSettings.yml │ │ │ └── defaultWorkspaces.js │ │ ├── empties/ │ │ │ ├── emptyContent.js │ │ │ ├── emptyContentState.js │ │ │ ├── emptyFile.js │ │ │ ├── emptyFolder.js │ │ │ ├── emptyPublishLocation.js │ │ │ ├── emptySyncLocation.js │ │ │ ├── emptySyncedContent.js │ │ │ ├── emptyTemplateHelpers.js │ │ │ └── emptyTemplateValue.html │ │ ├── faq.md │ │ ├── features.js │ │ ├── markdownSample.md │ │ ├── pagedownButtons.js │ │ ├── presets.js │ │ ├── simpleModals.js │ │ ├── templates/ │ │ │ ├── jekyllSiteTemplate.html │ │ │ ├── plainHtmlTemplate.html │ │ │ ├── styledHtmlTemplate.html │ │ │ └── styledHtmlWithTocTemplate.html │ │ └── welcomeFile.md │ ├── extensions/ │ │ ├── abcExtension.js │ │ ├── emojiExtension.js │ │ ├── index.js │ │ ├── katexExtension.js │ │ ├── libs/ │ │ │ ├── markdownItAnchor.js │ │ │ ├── markdownItMath.js │ │ │ └── markdownItTasklist.js │ │ ├── markdownExtension.js │ │ └── mermaidExtension.js │ ├── icons/ │ │ ├── Alert.vue │ │ ├── ArrowLeft.vue │ │ ├── CheckCircle.vue │ │ ├── Close.vue │ │ ├── CodeBraces.vue │ │ ├── CodeTags.vue │ │ ├── ContentCopy.vue │ │ ├── ContentSave.vue │ │ ├── Database.vue │ │ ├── Delete.vue │ │ ├── DotsHorizontal.vue │ │ ├── Download.vue │ │ ├── Eye.vue │ │ ├── FileImage.vue │ │ ├── FileMultiple.vue │ │ ├── FilePlus.vue │ │ ├── Folder.vue │ │ ├── FolderMultiple.vue │ │ ├── FolderPlus.vue │ │ ├── FormatBold.vue │ │ ├── FormatItalic.vue │ │ ├── FormatListBulleted.vue │ │ ├── FormatListChecks.vue │ │ ├── FormatListNumbers.vue │ │ ├── FormatQuoteClose.vue │ │ ├── FormatSize.vue │ │ ├── FormatStrikethrough.vue │ │ ├── HelpCircle.vue │ │ ├── History.vue │ │ ├── Information.vue │ │ ├── Key.vue │ │ ├── LinkVariant.vue │ │ ├── Login.vue │ │ ├── Logout.vue │ │ ├── Magnify.vue │ │ ├── Menu.vue │ │ ├── Message.vue │ │ ├── NavigationBar.vue │ │ ├── OpenInNew.vue │ │ ├── Pen.vue │ │ ├── Printer.vue │ │ ├── Provider.vue │ │ ├── Redo.vue │ │ ├── ScrollSync.vue │ │ ├── Seal.vue │ │ ├── Settings.vue │ │ ├── SidePreview.vue │ │ ├── SignalOff.vue │ │ ├── StatusBar.vue │ │ ├── Sync.vue │ │ ├── SyncOff.vue │ │ ├── Table.vue │ │ ├── Target.vue │ │ ├── Toc.vue │ │ ├── Undo.vue │ │ ├── Upload.vue │ │ ├── ViewList.vue │ │ └── index.js │ ├── index.js │ ├── libs/ │ │ ├── clunderscore.js │ │ ├── htmlSanitizer.js │ │ └── pagedown.js │ ├── services/ │ │ ├── animationSvc.js │ │ ├── backupSvc.js │ │ ├── badgeSvc.js │ │ ├── diffUtils.js │ │ ├── editor/ │ │ │ ├── cledit/ │ │ │ │ ├── cleditCore.js │ │ │ │ ├── cleditHighlighter.js │ │ │ │ ├── cleditKeystroke.js │ │ │ │ ├── cleditMarker.js │ │ │ │ ├── cleditSelectionMgr.js │ │ │ │ ├── cleditUndoMgr.js │ │ │ │ ├── cleditUtils.js │ │ │ │ ├── cleditWatcher.js │ │ │ │ └── index.js │ │ │ ├── editorSvcDiscussions.js │ │ │ ├── editorSvcUtils.js │ │ │ └── sectionUtils.js │ │ ├── editorSvc.js │ │ ├── explorerSvc.js │ │ ├── exportSvc.js │ │ ├── extensionSvc.js │ │ ├── gitWorkspaceSvc.js │ │ ├── localDbSvc.js │ │ ├── markdownConversionSvc.js │ │ ├── markdownGrammarSvc.js │ │ ├── networkSvc.js │ │ ├── optional/ │ │ │ ├── index.js │ │ │ ├── keystrokes.js │ │ │ ├── scrollSync.js │ │ │ ├── shortcuts.js │ │ │ └── taskChange.js │ │ ├── providers/ │ │ │ ├── bloggerPageProvider.js │ │ │ ├── bloggerProvider.js │ │ │ ├── common/ │ │ │ │ ├── Provider.js │ │ │ │ └── providerRegistry.js │ │ │ ├── couchdbWorkspaceProvider.js │ │ │ ├── dropboxProvider.js │ │ │ ├── gistProvider.js │ │ │ ├── githubProvider.js │ │ │ ├── githubWorkspaceProvider.js │ │ │ ├── gitlabProvider.js │ │ │ ├── gitlabWorkspaceProvider.js │ │ │ ├── googleDriveAppDataProvider.js │ │ │ ├── googleDriveProvider.js │ │ │ ├── googleDriveWorkspaceProvider.js │ │ │ ├── helpers/ │ │ │ │ ├── couchdbHelper.js │ │ │ │ ├── dropboxHelper.js │ │ │ │ ├── githubHelper.js │ │ │ │ ├── gitlabHelper.js │ │ │ │ ├── googleHelper.js │ │ │ │ ├── wordpressHelper.js │ │ │ │ └── zendeskHelper.js │ │ │ ├── wordpressProvider.js │ │ │ └── zendeskProvider.js │ │ ├── publishSvc.js │ │ ├── syncSvc.js │ │ ├── tempFileSvc.js │ │ ├── templateWorker.js │ │ ├── timeSvc.js │ │ ├── userSvc.js │ │ ├── utils.js │ │ └── workspaceSvc.js │ ├── store/ │ │ ├── content.js │ │ ├── contentState.js │ │ ├── contextMenu.js │ │ ├── data.js │ │ ├── discussion.js │ │ ├── explorer.js │ │ ├── file.js │ │ ├── findReplace.js │ │ ├── folder.js │ │ ├── index.js │ │ ├── layout.js │ │ ├── locationTemplate.js │ │ ├── modal.js │ │ ├── moduleTemplate.js │ │ ├── notification.js │ │ ├── queue.js │ │ ├── syncedContent.js │ │ ├── userInfo.js │ │ └── workspace.js │ └── styles/ │ ├── app.scss │ ├── base.scss │ ├── fonts.scss │ ├── index.js │ ├── markdownHighlighting.scss │ ├── prism.scss │ └── variables.scss ├── static/ │ ├── landing/ │ │ └── index.html │ ├── oauth2/ │ │ └── callback.html │ └── sitemap.xml └── test/ └── unit/ ├── .eslintrc ├── jest.conf.js ├── mocks/ │ ├── cryptoMock.js │ ├── localStorageMock.js │ ├── mutationObserverMock.js │ └── templateWorkerMock.js ├── setup.js └── specs/ ├── components/ │ ├── ButtonBar.spec.js │ ├── ContextMenu.spec.js │ ├── Explorer.spec.js │ ├── ExplorerNode.spec.js │ ├── NavigationBar.spec.js │ └── Notification.spec.js └── specUtils.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc ================================================ { "presets": [ ["env", { "modules": false }], "stage-2" ], "plugins": ["transform-runtime"], "comments": false, "env": { "test": { "presets": ["env", "stage-2"], "plugins": ["transform-es2015-modules-commonjs", "dynamic-import-node"] } } } ================================================ FILE: .dockerignore ================================================ node_modules .git dist .history ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 indent_style = space indent_size = 2 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true ================================================ FILE: .eslintignore ================================================ build/*.js config/*.js src/libs/*.js ================================================ FILE: .eslintrc.js ================================================ // http://eslint.org/docs/user-guide/configuring module.exports = { root: true, parser: 'babel-eslint', parserOptions: { sourceType: 'module' }, env: { browser: true, }, extends: 'airbnb-base', // required to lint *.vue files plugins: [ 'html' ], globals: { "NODE_ENV": false, "VERSION": false }, // check if imports actually resolve 'settings': { 'import/resolver': { 'webpack': { 'config': 'build/webpack.base.conf.js' } } }, // add your custom rules here 'rules': { 'no-param-reassign': [2, { 'props': false }], // don't require .vue extension when importing 'import/extensions': ['error', 'always', { 'js': 'never', 'vue': 'never' }], // allow optionalDependencies 'import/no-extraneous-dependencies': ['error', { 'optionalDependencies': ['test/unit/index.js'] }], // allow debugger during development 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 } } ================================================ FILE: .gitignore ================================================ .DS_Store node_modules/ dist/ .history .idea npm-debug.log* .vscode stackedit_v4 chrome-app/*.zip /test/unit/coverage/ ================================================ FILE: .postcssrc.js ================================================ // https://github.com/michael-ciniawsky/postcss-load-config module.exports = { "plugins": { // to edit target browsers: use "browserlist" field in package.json "autoprefixer": {} } } ================================================ FILE: .stylelintrc ================================================ { "processors": ["stylelint-processor-html"], "extends": "stylelint-config-standard", "rules": { "no-empty-source": null } } ================================================ FILE: .travis.yml ================================================ language: node_js node_js: - "12" services: - docker before_deploy: # Run docker build - docker build -t benweet/stackedit . # Install Helm - curl -SL -o /tmp/get_helm.sh https://git.io/get_helm.sh - chmod 700 /tmp/get_helm.sh - /tmp/get_helm.sh - helm init --client-only deploy: provider: script script: bash build/deploy.sh on: tags: true ================================================ FILE: Dockerfile ================================================ FROM benweet/stackedit-base RUN mkdir -p /opt/stackedit WORKDIR /opt/stackedit COPY package*json /opt/stackedit/ COPY gulpfile.js /opt/stackedit/ RUN npm install --unsafe-perm \ && npm cache clean --force COPY . /opt/stackedit ENV NODE_ENV production RUN npm run build EXPOSE 8080 CMD [ "node", "." ] ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright {yyyy} {name of copyright owner} Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # StackEdit [![Build Status](https://img.shields.io/travis/benweet/stackedit.svg?style=flat)](https://travis-ci.org/benweet/stackedit) [![NPM version](https://img.shields.io/npm/v/stackedit.svg?style=flat)](https://www.npmjs.org/package/stackedit) > Full-featured, open-source Markdown editor based on PageDown, the Markdown library used by Stack Overflow and the other Stack Exchange sites. https://stackedit.io/ ### Ecosystem - [Chrome app](https://chrome.google.com/webstore/detail/iiooodelglhkcpgbajoejffhijaclcdg) - NEW! Embed StackEdit in any website with [stackedit.js](https://github.com/benweet/stackedit.js) - NEW! [Chrome extension](https://chrome.google.com/webstore/detail/ajehldoplanpchfokmeempkekhnhmoha) that uses stackedit.js - [Community](https://community.stackedit.io/) ### Build ```bash # install dependencies npm install # serve with hot reload at localhost:8080 npm start # build for production with minification npm run build # build for production and view the bundle analyzer report npm run build --report ``` ### Deploy with Helm StackEdit Helm chart allows easy StackEdit deployment to any Kubernetes cluster. You can use it to configure deployment with your existing ingress controller and cert-manager. ```bash # Add the StackEdit Helm repository helm repo add stackedit https://benweet.github.io/stackedit-charts/ # Update your local Helm chart repository cache helm repo update # Deploy StackEdit chart to your cluster helm install --name stackedit stackedit/stackedit \ --set dropboxAppKey=$DROPBOX_API_KEY \ --set dropboxAppKeyFull=$DROPBOX_FULL_ACCESS_API_KEY \ --set googleClientId=$GOOGLE_CLIENT_ID \ --set googleApiKey=$GOOGLE_API_KEY \ --set githubClientId=$GITHUB_CLIENT_ID \ --set githubClientSecret=$GITHUB_CLIENT_SECRET \ --set wordpressClientId=\"$WORDPRESS_CLIENT_ID\" \ --set wordpressSecret=$WORDPRESS_CLIENT_SECRET ``` Later, to upgrade StackEdit to the latest version: ```bash helm repo update helm upgrade stackedit stackedit/stackedit ``` If you want to uninstall StackEdit: ```bash helm delete --purge stackedit ``` If you want to use your existing ingress controller and cert-manager issuer: ```bash # See https://docs.cert-manager.io/en/latest/tutorials/acme/quick-start/index.html helm install --name stackedit stackedit/stackedit \ --set dropboxAppKey=$DROPBOX_API_KEY \ --set dropboxAppKeyFull=$DROPBOX_FULL_ACCESS_API_KEY \ --set googleClientId=$GOOGLE_CLIENT_ID \ --set googleApiKey=$GOOGLE_API_KEY \ --set githubClientId=$GITHUB_CLIENT_ID \ --set githubClientSecret=$GITHUB_CLIENT_SECRET \ --set wordpressClientId=\"$WORDPRESS_CLIENT_ID\" \ --set wordpressSecret=$WORDPRESS_CLIENT_SECRET \ --set ingress.enabled=true \ --set ingress.annotations."kubernetes\.io/ingress\.class"=nginx \ --set ingress.annotations."cert-manager\.io/cluster-issuer"=letsencrypt-prod \ --set ingress.hosts[0].host=stackedit.example.com \ --set ingress.hosts[0].paths[0]=/ \ --set ingress.tls[0].secretName=stackedit-tls \ --set ingress.tls[0].hosts[0]=stackedit.example.com ``` ================================================ FILE: build/build.js ================================================ require('./check-versions')() process.env.NODE_ENV = 'production' var ora = require('ora') var rm = require('rimraf') var path = require('path') var chalk = require('chalk') var webpack = require('webpack') var config = require('../config') var webpackConfig = require('./webpack.prod.conf') var spinner = ora('building for production...') spinner.start() rm(path.join(config.build.assetsRoot, config.build.assetsSubDirectory), err => { if (err) throw err webpack(webpackConfig, function (err, stats) { spinner.stop() if (err) throw err process.stdout.write(stats.toString({ colors: true, modules: false, children: false, chunks: false, chunkModules: false }) + '\n\n') console.log(chalk.cyan(' Build complete.\n')) console.log(chalk.yellow( ' Tip: built files are meant to be served over an HTTP server.\n' + ' Opening index.html over file:// won\'t work.\n' )) }) }) ================================================ FILE: build/check-versions.js ================================================ var chalk = require('chalk') var semver = require('semver') var packageConfig = require('../package.json') var shell = require('shelljs') function exec (cmd) { return require('child_process').execSync(cmd).toString().trim() } var versionRequirements = [ { name: 'node', currentVersion: semver.clean(process.version), versionRequirement: packageConfig.engines.node }, ] if (shell.which('npm')) { versionRequirements.push({ name: 'npm', currentVersion: exec('npm --version'), versionRequirement: packageConfig.engines.npm }) } module.exports = function () { var warnings = [] for (var i = 0; i < versionRequirements.length; i++) { var mod = versionRequirements[i] if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) { warnings.push(mod.name + ': ' + chalk.red(mod.currentVersion) + ' should be ' + chalk.green(mod.versionRequirement) ) } } if (warnings.length) { console.log('') console.log(chalk.yellow('To use this template, you must update following to modules:')) console.log() for (var i = 0; i < warnings.length; i++) { var warning = warnings[i] console.log(' ' + warning) } console.log() process.exit(1) } } ================================================ FILE: build/deploy.sh ================================================ #!/bin/bash set -e # Tag and push docker image docker login -u benweet -p "$DOCKER_PASSWORD" docker tag benweet/stackedit "benweet/stackedit:$TRAVIS_TAG" docker push benweet/stackedit:$TRAVIS_TAG docker tag benweet/stackedit:$TRAVIS_TAG benweet/stackedit:latest docker push benweet/stackedit:latest # Build the chart cd "$TRAVIS_BUILD_DIR" npm run chart # Add chart to helm repository git clone --branch master "https://benweet:$GITHUB_TOKEN@github.com/benweet/stackedit-charts.git" /tmp/charts cd /tmp/charts helm package "$TRAVIS_BUILD_DIR/dist/stackedit" helm repo index --url https://benweet.github.io/stackedit-charts/ . git config user.name "Benoit Schweblin" git config user.email "benoit.schweblin@gmail.com" git add . git commit -m "Added $TRAVIS_TAG" git push origin master ================================================ FILE: build/dev-client.js ================================================ /* eslint-disable */ require('eventsource-polyfill') var hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true') hotClient.subscribe(function (event) { if (event.action === 'reload') { window.location.reload() } }) ================================================ FILE: build/dev-server.js ================================================ require('./check-versions')() var config = require('../config') Object.keys(config.dev.env).forEach((key) => { if (!process.env[key]) { process.env[key] = JSON.parse(config.dev.env[key]); } }); var opn = require('opn') var path = require('path') var express = require('express') var webpack = require('webpack') var proxyMiddleware = require('http-proxy-middleware') var webpackConfig = require('./webpack.dev.conf') // default port where dev server listens for incoming traffic var port = process.env.PORT || config.dev.port // automatically open browser, if not set will be false var autoOpenBrowser = !!config.dev.autoOpenBrowser // Define HTTP proxies to your custom API backend // https://github.com/chimurai/http-proxy-middleware var proxyTable = config.dev.proxyTable var app = express() var compiler = webpack(webpackConfig) // StackEdit custom middlewares require('../server')(app); var devMiddleware = require('webpack-dev-middleware')(compiler, { publicPath: webpackConfig.output.publicPath, quiet: true }) var hotMiddleware = require('webpack-hot-middleware')(compiler, { log: () => {} }) // force page reload when html-webpack-plugin template changes compiler.plugin('compilation', function (compilation) { compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) { hotMiddleware.publish({ action: 'reload' }) cb() }) }) // proxy api requests Object.keys(proxyTable).forEach(function (context) { var options = proxyTable[context] if (typeof options === 'string') { options = { target: options } } app.use(proxyMiddleware(options.filter || context, options)) }) // handle fallback for HTML5 history API app.use(require('connect-history-api-fallback')()) // serve webpack bundle output app.use(devMiddleware) // enable hot-reload and state-preserving // compilation error display app.use(hotMiddleware) // serve pure static assets var staticPath = path.posix.join(config.dev.assetsPublicPath, config.dev.assetsSubDirectory) app.use(staticPath, express.static('./static')) var uri = 'http://localhost:' + port var _resolve var readyPromise = new Promise(resolve => { _resolve = resolve }) console.log('> Starting dev server...') devMiddleware.waitUntilValid(() => { console.log('> Listening at ' + uri + '\n') // when env is testing, don't need open it if (autoOpenBrowser && process.env.NODE_ENV !== 'testing') { opn(uri) } _resolve() }) var server = app.listen(port) module.exports = { ready: readyPromise, close: () => { server.close() } } ================================================ FILE: build/utils.js ================================================ var path = require('path') var config = require('../config') var ExtractTextPlugin = require('extract-text-webpack-plugin') exports.assetsPath = function (_path) { var assetsSubDirectory = process.env.NODE_ENV === 'production' ? config.build.assetsSubDirectory : config.dev.assetsSubDirectory return path.posix.join(assetsSubDirectory, _path) } exports.cssLoaders = function (options) { options = options || {} var cssLoader = { loader: 'css-loader', options: { minimize: process.env.NODE_ENV === 'production', sourceMap: options.sourceMap } } // generate loader string to be used with extract text plugin function generateLoaders (loader, loaderOptions) { var loaders = [cssLoader] if (loader) { loaders.push({ loader: loader + '-loader', options: Object.assign({}, loaderOptions, { sourceMap: options.sourceMap }) }) } // Extract CSS when that option is specified // (which is the case during production build) if (options.extract) { return ExtractTextPlugin.extract({ use: loaders, fallback: 'vue-style-loader' }) } else { return ['vue-style-loader'].concat(loaders) } } // https://vue-loader.vuejs.org/en/configurations/extract-css.html return { css: generateLoaders(), postcss: generateLoaders(), less: generateLoaders('less'), sass: generateLoaders('sass', { indentedSyntax: true }), scss: generateLoaders('sass'), stylus: generateLoaders('stylus'), styl: generateLoaders('stylus') } } // Generate loaders for standalone style files (outside of .vue) exports.styleLoaders = function (options) { var output = [] var loaders = exports.cssLoaders(options) for (var extension in loaders) { var loader = loaders[extension] output.push({ test: new RegExp('\\.' + extension + '$'), use: loader }) } return output } ================================================ FILE: build/vue-loader.conf.js ================================================ var utils = require('./utils') var config = require('../config') var isProduction = process.env.NODE_ENV === 'production' module.exports = { loaders: utils.cssLoaders({ sourceMap: isProduction ? config.build.productionSourceMap : config.dev.cssSourceMap, extract: isProduction }) } ================================================ FILE: build/webpack.base.conf.js ================================================ var path = require('path') var webpack = require('webpack') var utils = require('./utils') var config = require('../config') var VueLoaderPlugin = require('vue-loader/lib/plugin') var vueLoaderConfig = require('./vue-loader.conf') var StylelintPlugin = require('stylelint-webpack-plugin') function resolve (dir) { return path.join(__dirname, '..', dir) } module.exports = { entry: { app: './src/' }, node: { // For mermaid fs: 'empty' // jison generated code requires 'fs' }, output: { path: config.build.assetsRoot, filename: '[name].js', publicPath: process.env.NODE_ENV === 'production' ? config.build.assetsPublicPath : config.dev.assetsPublicPath }, resolve: { extensions: ['.js', '.vue', '.json'], alias: { '@': resolve('src') } }, module: { rules: [ { test: /\.(js|vue)$/, loader: 'eslint-loader', enforce: 'pre', include: [resolve('src'), resolve('test')], options: { formatter: require('eslint-friendly-formatter') } }, { test: /\.vue$/, loader: 'vue-loader', options: vueLoaderConfig }, // We can't pass graphlibrary to babel { test: /\.js$/, loader: 'string-replace-loader', include: [ resolve('node_modules/graphlibrary') ], options: { search: '^\\s*(?:let|const) ', replace: 'var ', flags: 'gm' } }, { test: /\.js$/, loader: 'babel-loader', include: [ resolve('src'), resolve('test'), resolve('node_modules/mermaid') ], exclude: [ resolve('node_modules/mermaid/src/diagrams/class/parser'), resolve('node_modules/mermaid/src/diagrams/flowchart/parser'), resolve('node_modules/mermaid/src/diagrams/gantt/parser'), resolve('node_modules/mermaid/src/diagrams/git/parser'), resolve('node_modules/mermaid/src/diagrams/sequence/parser') ], }, { test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, loader: 'url-loader', options: { limit: 10000, name: utils.assetsPath('img/[name].[hash:7].[ext]') } }, { test: /\.(ttf|eot|otf|woff2?)(\?.*)?$/, loader: 'file-loader', options: { name: utils.assetsPath('fonts/[name].[hash:7].[ext]') } }, { test: /\.(md|yml|html)$/, loader: 'raw-loader' } ] }, plugins: [ new VueLoaderPlugin(), new StylelintPlugin({ files: ['**/*.vue', '**/*.scss'] }), new webpack.DefinePlugin({ VERSION: JSON.stringify(require('../package.json').version) }) ] } ================================================ FILE: build/webpack.dev.conf.js ================================================ var utils = require('./utils') var webpack = require('webpack') var config = require('../config') var merge = require('webpack-merge') var baseWebpackConfig = require('./webpack.base.conf') var HtmlWebpackPlugin = require('html-webpack-plugin') var FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin') // add hot-reload related code to entry chunks Object.keys(baseWebpackConfig.entry).forEach(function (name) { baseWebpackConfig.entry[name] = ['./build/dev-client'].concat(baseWebpackConfig.entry[name]) }) module.exports = merge(baseWebpackConfig, { module: { rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap }) }, // cheap-module-eval-source-map is faster for development devtool: 'source-map', plugins: [ new webpack.DefinePlugin({ NODE_ENV: config.dev.env.NODE_ENV }), // https://github.com/glenjamin/webpack-hot-middleware#installation--usage new webpack.HotModuleReplacementPlugin(), new webpack.NoEmitOnErrorsPlugin(), // https://github.com/ampedandwired/html-webpack-plugin new HtmlWebpackPlugin({ filename: 'index.html', template: 'index.html', inject: true }), new FriendlyErrorsPlugin() ] }) ================================================ FILE: build/webpack.prod.conf.js ================================================ var path = require('path') var utils = require('./utils') var webpack = require('webpack') var config = require('../config') var merge = require('webpack-merge') var baseWebpackConfig = require('./webpack.base.conf') var CopyWebpackPlugin = require('copy-webpack-plugin') var HtmlWebpackPlugin = require('html-webpack-plugin') var ExtractTextPlugin = require('extract-text-webpack-plugin') var OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin') var OfflinePlugin = require('offline-plugin'); var WebpackPwaManifest = require('webpack-pwa-manifest') var FaviconsWebpackPlugin = require('favicons-webpack-plugin') function resolve (dir) { return path.join(__dirname, '..', dir) } var env = config.build.env var webpackConfig = merge(baseWebpackConfig, { module: { rules: utils.styleLoaders({ sourceMap: config.build.productionSourceMap, extract: true }) }, devtool: config.build.productionSourceMap ? '#source-map' : false, output: { path: config.build.assetsRoot, filename: utils.assetsPath('js/[name].[chunkhash].js'), chunkFilename: utils.assetsPath('js/[id].[chunkhash].js') }, plugins: [ // http://vuejs.github.io/vue-loader/en/workflow/production.html new webpack.DefinePlugin({ NODE_ENV: env.NODE_ENV, GOOGLE_CLIENT_ID: env.GOOGLE_CLIENT_ID, GITHUB_CLIENT_ID: env.GITHUB_CLIENT_ID }), new webpack.optimize.UglifyJsPlugin({ compress: { warnings: false }, sourceMap: true }), // extract css into its own file new ExtractTextPlugin({ filename: utils.assetsPath('css/[name].[contenthash].css') }), // Compress extracted CSS. We are using this plugin so that possible // duplicated CSS from different components can be deduped. new OptimizeCSSPlugin({ cssProcessorOptions: { safe: true } }), // generate dist index.html with correct asset hash for caching. // you can customize output by editing /index.html // see https://github.com/ampedandwired/html-webpack-plugin new HtmlWebpackPlugin({ filename: config.build.index, template: 'index.html', inject: true, minify: { removeComments: true, collapseWhitespace: true, removeAttributeQuotes: true // more options: // https://github.com/kangax/html-minifier#options-quick-reference }, // necessary to consistently work with multiple chunks via CommonsChunkPlugin chunksSortMode: 'dependency' }), // split vendor js into its own file new webpack.optimize.CommonsChunkPlugin({ name: 'vendor', minChunks: function (module, count) { // any required modules inside node_modules are extracted to vendor return ( module.resource && /\.js$/.test(module.resource) && module.resource.indexOf( path.join(__dirname, '../node_modules') ) === 0 ) } }), // extract webpack runtime and module manifest to its own file in order to // prevent vendor hash from being updated whenever app bundle is updated new webpack.optimize.CommonsChunkPlugin({ name: 'manifest', chunks: ['vendor'] }), // copy custom static assets new CopyWebpackPlugin([ { from: path.resolve(__dirname, '../static'), to: config.build.assetsSubDirectory, ignore: ['.*'] } ]), new FaviconsWebpackPlugin({ logo: resolve('src/assets/favicon.png'), title: 'StackEdit', }), new WebpackPwaManifest({ name: 'StackEdit', description: 'Full-featured, open-source Markdown editor', display: 'standalone', orientation: 'any', start_url: 'app', background_color: '#ffffff', crossorigin: 'use-credentials', icons: [{ src: resolve('src/assets/favicon.png'), sizes: [96, 128, 192, 256, 384, 512] }] }), new OfflinePlugin({ ServiceWorker: { events: true }, AppCache: true, excludes: ['**/.*', '**/*.map', '**/index.html', '**/static/oauth2/callback.html', '**/icons-*/*.png', '**/static/fonts/KaTeX_*'], externals: ['/', '/app', '/oauth2/callback'] }), ] }) if (config.build.productionGzip) { var CompressionWebpackPlugin = require('compression-webpack-plugin') webpackConfig.plugins.push( new CompressionWebpackPlugin({ asset: '[path].gz[query]', algorithm: 'gzip', test: new RegExp( '\\.(' + config.build.productionGzipExtensions.join('|') + ')$' ), threshold: 10240, minRatio: 0.8 }) ) } if (config.build.bundleAnalyzerReport) { var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin webpackConfig.plugins.push(new BundleAnalyzerPlugin()) } module.exports = webpackConfig ================================================ FILE: build/webpack.style.conf.js ================================================ var path = require('path') var utils = require('./utils') var webpack = require('webpack') var utils = require('./utils') var config = require('../config') var vueLoaderConfig = require('./vue-loader.conf') var StylelintPlugin = require('stylelint-webpack-plugin') var ExtractTextPlugin = require('extract-text-webpack-plugin') var OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin') function resolve (dir) { return path.join(__dirname, '..', dir) } module.exports = { entry: { style: './src/styles/' }, module: { rules: [{ test: /\.(ttf|eot|otf|woff2?)(\?.*)?$/, loader: 'file-loader', options: { name: utils.assetsPath('fonts/[name].[hash:7].[ext]') } }] .concat(utils.styleLoaders({ sourceMap: config.build.productionSourceMap, extract: true })), }, output: { path: config.build.assetsRoot, filename: '[name].js', publicPath: config.build.assetsPublicPath }, plugins: [ new webpack.optimize.UglifyJsPlugin({ compress: { warnings: false }, sourceMap: true }), // extract css into its own file new ExtractTextPlugin({ filename: '[name].css', }), // Compress extracted CSS. We are using this plugin so that possible // duplicated CSS from different components can be deduped. new OptimizeCSSPlugin({ cssProcessorOptions: { safe: true } }), ] } ================================================ FILE: chart/.helmignore ================================================ # Patterns to ignore when building packages. # This supports shell glob matching, relative path matching, and # negation (prefixed with !). Only one pattern per line. .DS_Store # Common VCS dirs .git/ .gitignore .bzr/ .bzrignore .hg/ .hgignore .svn/ # Common backup files *.swp *.bak *.tmp *~ # Various IDEs .project .idea/ *.tmproj .vscode/ ================================================ FILE: chart/Chart.yaml ================================================ apiVersion: v1 appVersion: vSTACKEDIT_VERSION description: In-browser Markdown editor name: stackedit version: STACKEDIT_VERSION ================================================ FILE: chart/templates/NOTES.txt ================================================ 1. Get the application URL by running these commands: {{- if .Values.ingress.enabled }} {{- range $host := .Values.ingress.hosts }} {{- range .paths }} http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }} {{- end }} {{- end }} {{- else if contains "NodePort" .Values.service.type }} export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "stackedit.fullname" . }}) export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") echo http://$NODE_IP:$NODE_PORT {{- else if contains "LoadBalancer" .Values.service.type }} NOTE: It may take a few minutes for the LoadBalancer IP to be available. You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "stackedit.fullname" . }}' export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "stackedit.fullname" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}') echo http://$SERVICE_IP:{{ .Values.service.port }} {{- else if contains "ClusterIP" .Values.service.type }} export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "stackedit.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") echo "Visit http://127.0.0.1:8080 to use your application" kubectl port-forward $POD_NAME 8080:80 {{- end }} ================================================ FILE: chart/templates/_helpers.tpl ================================================ {{/* vim: set filetype=mustache: */}} {{/* Expand the name of the chart. */}} {{- define "stackedit.name" -}} {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} {{- end -}} {{/* Create a default fully qualified app name. We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). If release name contains chart name it will be used as a full name. */}} {{- define "stackedit.fullname" -}} {{- if .Values.fullnameOverride -}} {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} {{- else -}} {{- $name := default .Chart.Name .Values.nameOverride -}} {{- if contains $name .Release.Name -}} {{- .Release.Name | trunc 63 | trimSuffix "-" -}} {{- else -}} {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} {{- end -}} {{- end -}} {{- end -}} {{/* Create chart name and version as used by the chart label. */}} {{- define "stackedit.chart" -}} {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} {{- end -}} {{/* Common labels */}} {{- define "stackedit.labels" -}} app.kubernetes.io/name: {{ include "stackedit.name" . }} helm.sh/chart: {{ include "stackedit.chart" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- if .Chart.AppVersion }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} {{- end }} app.kubernetes.io/managed-by: {{ .Release.Service }} {{- end -}} ================================================ FILE: chart/templates/deployment.yaml ================================================ apiVersion: apps/v1 kind: Deployment metadata: name: {{ include "stackedit.fullname" . }} labels: {{ include "stackedit.labels" . | indent 4 }} spec: replicas: {{ .Values.replicaCount }} selector: matchLabels: app.kubernetes.io/name: {{ include "stackedit.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} template: metadata: labels: app.kubernetes.io/name: {{ include "stackedit.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} spec: {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} containers: - name: {{ .Chart.Name }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" imagePullPolicy: {{ .Values.image.pullPolicy }} volumeMounts: - mountPath: /run name: run-volume - mountPath: /tmp name: tmp-volume env: - name: PORT value: "80" - name: PAYPAL_RECEIVER_EMAIL value: {{ .Values.paypalReceiverEmail }} - name: AWS_ACCESS_KEY_ID value: {{ .Values.awsAccessKeyId }} - name: AWS_SECRET_ACCESS_KEY value: {{ .Values.awsSecretAccessKey }} - name: DROPBOX_APP_KEY value: {{ .Values.dropboxAppKey }} - name: DROPBOX_APP_KEY_FULL value: {{ .Values.dropboxAppKeyFull }} - name: GOOGLE_CLIENT_ID value: {{ .Values.googleClientId }} - name: GOOGLE_API_KEY value: {{ .Values.googleApiKey }} - name: GITHUB_CLIENT_ID value: {{ .Values.githubClientId }} - name: GITHUB_CLIENT_SECRET value: {{ .Values.githubClientSecret }} - name: WORDPRESS_CLIENT_ID value: {{ .Values.wordpressClientId }} - name: WORDPRESS_SECRET value: {{ .Values.wordpressSecret }} ports: - name: http containerPort: 80 protocol: TCP livenessProbe: httpGet: path: / port: http readinessProbe: httpGet: path: / port: http resources: {{- toYaml .Values.resources | nindent 12 }} volumes: - name: run-volume emptyDir: {} - name: tmp-volume emptyDir: {} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} {{- end }} {{- with .Values.affinity }} affinity: {{- toYaml . | nindent 8 }} {{- end }} {{- with .Values.tolerations }} tolerations: {{- toYaml . | nindent 8 }} {{- end }} ================================================ FILE: chart/templates/ingress.yaml ================================================ {{- if .Values.ingress.enabled -}} {{- $fullName := include "stackedit.fullname" . -}} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ $fullName }} labels: {{ include "stackedit.labels" . | indent 4 }} {{- with .Values.ingress.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} spec: {{- if .Values.ingress.tls }} tls: {{- range .Values.ingress.tls }} - hosts: {{- range .hosts }} - {{ . | quote }} {{- end }} secretName: {{ .secretName }} {{- end }} {{- end }} rules: {{- range .Values.ingress.hosts }} - host: {{ .host | quote }} http: paths: {{- range .paths }} - path: {{ . }} pathType: Prefix backend: service: name: {{ $fullName }} port: name: http {{- end }} {{- end }} {{- end }} ================================================ FILE: chart/templates/service.yaml ================================================ apiVersion: v1 kind: Service metadata: name: {{ include "stackedit.fullname" . }} labels: {{ include "stackedit.labels" . | indent 4 }} spec: type: {{ .Values.service.type }} ports: - port: {{ .Values.service.port }} targetPort: http protocol: TCP name: http selector: app.kubernetes.io/name: {{ include "stackedit.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} ================================================ FILE: chart/templates/tests/test-connection.yaml ================================================ apiVersion: v1 kind: Pod metadata: name: "{{ include "stackedit.fullname" . }}-test-connection" labels: {{ include "stackedit.labels" . | indent 4 }} annotations: "helm.sh/hook": test-success spec: containers: - name: wget image: busybox command: ['wget'] args: ['{{ include "stackedit.fullname" . }}:{{ .Values.service.port }}'] restartPolicy: Never ================================================ FILE: chart/values.yaml ================================================ # Default values for stackedit. # This is a YAML-formatted file. # Declare variables to be passed into your templates. dropboxAppKey: "" dropboxAppKeyFull: "" googleClientId: "" googleApiKey: "" githubClientId: "" githubClientSecret: "" wordpressClientId: "" wordpressSecret: "" paypalReceiverEmail: "" awsAccessKeyId: "" awsSecretAccessKey: "" replicaCount: 1 image: repository: benweet/stackedit tag: vSTACKEDIT_VERSION pullPolicy: IfNotPresent imagePullSecrets: [] nameOverride: "" fullnameOverride: "" service: type: ClusterIP port: 80 ingress: enabled: false annotations: # kubernetes.io/ingress.class: nginx # certmanager.k8s.io/issuer: letsencrypt-prod # certmanager.k8s.io/acme-challenge-type: http01 hosts: [] # - host: stackedit.example.com # paths: # - / tls: [] # - secretName: stackedit-tls # hosts: # - stackedit.example.com resources: {} # We usually recommend not to specify default resources and to leave this as a conscious # choice for the user. This also increases chances charts run on environments with little # resources, such as Minikube. If you do want to specify resources, uncomment the following # lines, adjust them as necessary, and remove the curly braces after 'resources:'. # limits: # cpu: 100m # memory: 128Mi # requests: # cpu: 100m # memory: 128Mi nodeSelector: {} tolerations: [] affinity: {} ================================================ FILE: chrome-app/manifest.json ================================================ { "name": "StackEdit", "description": "In-browser Markdown editor", "version": "1.0.13", "manifest_version": 2, "container" : "GOOGLE_DRIVE", "api_console_project_id" : "241271498917", "icons": { "16": "icon-16.png", "32": "icon-32.png", "64": "icon-64.png", "128": "icon-128.png", "256": "icon-256.png", "512": "icon-512.png" }, "app": { "urls": [ "https://stackedit.io/" ], "launch": { "web_url": "https://stackedit.io/app" } }, "offline_enabled": true, "permissions": [ "unlimitedStorage" ] } ================================================ FILE: config/dev.env.js ================================================ var merge = require('webpack-merge') var prodEnv = require('./prod.env') module.exports = merge(prodEnv, { NODE_ENV: '"development"' }) ================================================ FILE: config/index.js ================================================ // see http://vuejs-templates.github.io/webpack for documentation. var path = require('path') module.exports = { build: { env: require('./prod.env'), index: path.resolve(__dirname, '../dist/index.html'), assetsRoot: path.resolve(__dirname, '../dist'), assetsSubDirectory: 'static', assetsPublicPath: '/', productionSourceMap: true, // Gzip off by default as many popular static hosts such as // Surge or Netlify already gzip all static assets for you. // Before setting to `true`, make sure to: // npm install --save-dev compression-webpack-plugin productionGzip: false, productionGzipExtensions: ['js', 'css'], // Run the build command with an extra argument to // View the bundle analyzer report after build finishes: // `npm run build --report` // Set to `true` or `false` to always turn it on or off bundleAnalyzerReport: process.env.npm_config_report }, dev: { env: require('./dev.env'), port: 8080, autoOpenBrowser: false, assetsSubDirectory: 'static', assetsPublicPath: '/', proxyTable: {}, // CSS Sourcemaps off by default because relative paths are "buggy" // with this option, according to the CSS-Loader README // (https://github.com/webpack/css-loader#sourcemaps) // In our experience, they generally work as expected, // just be aware of this issue when enabling this option. // cssSourceMap: false cssSourceMap: true } } ================================================ FILE: config/prod.env.js ================================================ module.exports = { NODE_ENV: '"production"' } ================================================ FILE: gulpfile.js ================================================ const path = require('path'); const gulp = require('gulp'); const concat = require('gulp-concat'); const prismScripts = [ 'prismjs/components/prism-core', 'prismjs/components/prism-markup', 'prismjs/components/prism-clike', 'prismjs/components/prism-c', 'prismjs/components/prism-javascript', 'prismjs/components/prism-css', 'prismjs/components/prism-ruby', 'prismjs/components/prism-cpp', ].map(require.resolve); prismScripts.push( path.join(path.dirname(require.resolve('prismjs/components/prism-core')), 'prism-!(*.min).js')); gulp.task('build-prism', () => gulp.src(prismScripts) .pipe(concat('prism.js')) .pipe(gulp.dest(path.dirname(require.resolve('prismjs'))))); ================================================ FILE: index.html ================================================ StackEdit
================================================ FILE: index.js ================================================ const env = require('./config/prod.env'); Object.keys(env).forEach((key) => { if (!process.env[key]) { process.env[key] = JSON.parse(env[key]); } }); const http = require('http'); const express = require('express'); const app = express(); require('./server')(app); const port = parseInt(process.env.PORT || 8080, 10); const httpServer = http.createServer(app); httpServer.listen(port, null, () => { console.log(`HTTP server started: http://localhost:${port}`); }); // Handle graceful shutdown process.on('SIGTERM', () => { httpServer.close(() => { process.exit(0); }); }); ================================================ FILE: package.json ================================================ { "name": "stackedit", "version": "5.15.4", "description": "Free, open-source, full-featured Markdown editor", "author": "Benoit Schweblin", "license": "Apache-2.0", "bugs": { "url": "https://github.com/benweet/stackedit/issues" }, "main": "index.js", "scripts": { "postinstall": "gulp build-prism", "start": "node build/dev-server.js", "build": "node build/build.js && npm run build-style", "build-style": "webpack --config build/webpack.style.conf.js", "lint": "eslint --ext .js,.vue src server", "unit": "jest --config test/unit/jest.conf.js --runInBand", "unit-with-coverage": "jest --config test/unit/jest.conf.js --runInBand --coverage", "test": "npm run lint && npm run unit", "preversion": "npm run test", "postversion": "git push origin master --tags && npm publish", "patch": "npm version patch -m \"Tag v%s\"", "minor": "npm version minor -m \"Tag v%s\"", "major": "npm version major -m \"Tag v%s\"", "chart": "mkdir -p dist && rm -rf dist/stackedit && cp -r chart dist/stackedit && sed -i.bak -e s/STACKEDIT_VERSION/$npm_package_version/g dist/stackedit/*.yaml && rm dist/stackedit/*.yaml.bak" }, "dependencies": { "@vue/test-utils": "^1.0.0-beta.16", "abcjs": "^5.2.0", "aws-sdk": "^2.1380.0", "babel-runtime": "^6.26.0", "bezier-easing": "^1.1.0", "body-parser": "^1.18.2", "clipboard": "^1.7.1", "compression": "^1.7.0", "diff-match-patch": "^1.0.0", "file-saver": "^1.3.8", "google-id-token-verifier": "^0.2.3", "handlebars": "^4.0.10", "indexeddbshim": "^3.6.2", "js-yaml": "^3.11.0", "katex": "^0.13.0", "markdown-it": "^8.4.1", "markdown-it-abbr": "^1.0.4", "markdown-it-deflist": "^2.0.2", "markdown-it-emoji": "^1.3.0", "markdown-it-footnote": "^3.0.1", "markdown-it-imsize": "^2.0.1", "markdown-it-mark": "^2.0.0", "markdown-it-pandoc-renderer": "1.2.0", "markdown-it-sub": "^1.0.0", "markdown-it-sup": "^1.0.0", "mermaid": "^8.9.2", "mousetrap": "^1.6.1", "normalize-scss": "^7.0.1", "prismjs": "^1.6.0", "request": "^2.85.0", "serve-static": "^1.13.2", "tmp": "^0.0.33", "turndown": "^4.0.2", "vue": "^2.5.16", "vuex": "^3.0.1" }, "devDependencies": { "autoprefixer": "^6.7.2", "babel-core": "^6.26.3", "babel-eslint": "^8.2.3", "babel-jest": "^21.0.2", "babel-loader": "^7.1.4", "babel-plugin-dynamic-import-node": "^1.2.0", "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2", "babel-plugin-transform-runtime": "^6.23.0", "babel-polyfill": "^6.23.0", "babel-preset-env": "^1.7.0", "babel-preset-stage-2": "^6.22.0", "babel-register": "^6.22.0", "chalk": "^1.1.3", "connect-history-api-fallback": "^1.3.0", "copy-webpack-plugin": "^4.5.1", "css-loader": "^0.28.11", "eslint": "^4.19.1", "eslint-config-airbnb-base": "^12.1.0", "eslint-friendly-formatter": "^4.0.1", "eslint-import-resolver-webpack": "^0.9.0", "eslint-loader": "^2.0.0", "eslint-plugin-html": "^4.0.3", "eslint-plugin-import": "^2.11.0", "eventsource-polyfill": "^0.9.6", "express": "^4.16.3", "extract-text-webpack-plugin": "^2.0.0", "favicons-webpack-plugin": "^0.0.9", "file-loader": "^1.1.11", "friendly-errors-webpack-plugin": "^1.7.0", "gulp": "^4.0.2", "gulp-concat": "^2.6.1", "html-webpack-plugin": "^3.2.0", "http-proxy-middleware": "^0.18.0", "identity-obj-proxy": "^3.0.0", "ignore-loader": "^0.1.2", "jest": "^23.0.0", "jest-raw-loader": "^1.0.1", "jest-serializer-vue": "^0.3.0", "node-sass": "^4.0.0", "npm-bump": "^0.0.23", "offline-plugin": "^5.0.3", "opn": "^4.0.2", "optimize-css-assets-webpack-plugin": "^1.3.2", "ora": "^1.2.0", "raw-loader": "^0.5.1", "replace-in-file": "^4.1.0", "rimraf": "^2.6.0", "sass-loader": "^7.0.1", "semver": "^5.5.0", "shelljs": "^0.8.1", "string-replace-loader": "^2.1.1", "stylelint": "^9.2.0", "stylelint-config-standard": "^16.0.0", "stylelint-processor-html": "^1.0.0", "stylelint-webpack-plugin": "^0.10.4", "url-loader": "^1.0.1", "vue-jest": "^1.0.2", "vue-loader": "^15.0.9", "vue-style-loader": "^4.1.0", "vue-template-compiler": "^2.5.16", "webpack": "^2.6.1", "webpack-bundle-analyzer": "^3.3.2", "webpack-dev-middleware": "^1.10.0", "webpack-hot-middleware": "^2.18.0", "webpack-merge": "^4.1.2", "webpack-pwa-manifest": "^3.7.1", "worker-loader": "^1.1.1" }, "engines": { "node": ">= 8.0.0", "npm": ">= 5.0.0" }, "browserslist": [ "> 1%", "last 2 versions", "not ie <= 10" ] } ================================================ FILE: server/conf.js ================================================ const pandocPath = process.env.PANDOC_PATH || 'pandoc'; const wkhtmltopdfPath = process.env.WKHTMLTOPDF_PATH || 'wkhtmltopdf'; const userBucketName = process.env.USER_BUCKET_NAME || 'stackedit-users'; const paypalUri = process.env.PAYPAL_URI || 'https://www.paypal.com/cgi-bin/webscr'; const paypalReceiverEmail = process.env.PAYPAL_RECEIVER_EMAIL; const dropboxAppKey = process.env.DROPBOX_APP_KEY; const dropboxAppKeyFull = process.env.DROPBOX_APP_KEY_FULL; const githubClientId = process.env.GITHUB_CLIENT_ID; const githubClientSecret = process.env.GITHUB_CLIENT_SECRET; const googleClientId = process.env.GOOGLE_CLIENT_ID; const googleApiKey = process.env.GOOGLE_API_KEY; const wordpressClientId = process.env.WORDPRESS_CLIENT_ID; exports.values = { pandocPath, wkhtmltopdfPath, userBucketName, paypalUri, paypalReceiverEmail, dropboxAppKey, dropboxAppKeyFull, githubClientId, githubClientSecret, googleClientId, googleApiKey, wordpressClientId, }; exports.publicValues = { dropboxAppKey, dropboxAppKeyFull, githubClientId, googleClientId, googleApiKey, wordpressClientId, allowSponsorship: !!paypalReceiverEmail, }; ================================================ FILE: server/github.js ================================================ const qs = require('qs'); // eslint-disable-line import/no-extraneous-dependencies const request = require('request'); const conf = require('./conf'); function githubToken(clientId, code) { return new Promise((resolve, reject) => { request({ method: 'POST', url: 'https://github.com/login/oauth/access_token', qs: { client_id: clientId, client_secret: conf.values.githubClientSecret, code, }, }, (err, res, body) => { if (err) { reject(err); } const token = qs.parse(body).access_token; if (token) { resolve(token); } else { reject(res.statusCode); } }); }); } exports.githubToken = (req, res) => { githubToken(req.query.clientId, req.query.code) .then( token => res.send(token), err => res .status(400) .send(err ? err.message || err.toString() : 'bad_code'), ); }; ================================================ FILE: server/index.js ================================================ const compression = require('compression'); const serveStatic = require('serve-static'); const bodyParser = require('body-parser'); const path = require('path'); const user = require('./user'); const github = require('./github'); const pdf = require('./pdf'); const pandoc = require('./pandoc'); const conf = require('./conf'); const resolvePath = pathToResolve => path.join(__dirname, '..', pathToResolve); module.exports = (app) => { if (process.env.NODE_ENV === 'production') { // Enable CORS for fonts app.all('*', (req, res, next) => { if (/\.(eot|ttf|woff2?|svg)$/.test(req.url)) { res.header('Access-Control-Allow-Origin', '*'); } next(); }); // Use gzip compression app.use(compression()); } app.get('/oauth2/githubToken', github.githubToken); app.get('/conf', (req, res) => res.send(conf.publicValues)); app.get('/userInfo', user.userInfo); app.post('/pdfExport', pdf.generate); app.post('/pandocExport', pandoc.generate); app.post('/paypalIpn', bodyParser.urlencoded({ extended: false, }), user.paypalIpn); // Serve landing.html app.get('/', (req, res) => res.sendFile(resolvePath('static/landing/index.html'))); // Serve sitemap.xml app.get('/sitemap.xml', (req, res) => res.sendFile(resolvePath('static/sitemap.xml'))); // Serve callback.html app.get('/oauth2/callback', (req, res) => res.sendFile(resolvePath('static/oauth2/callback.html'))); // Google Drive action receiver app.get('/googleDriveAction', (req, res) => res.redirect(`./app#providerId=googleDrive&state=${encodeURIComponent(req.query.state)}`)); // Serve static resources if (process.env.NODE_ENV === 'production') { // Serve index.html in /app app.get('/app', (req, res) => res.sendFile(resolvePath('dist/index.html'))); // Serve style.css with 1 day max-age app.get('/style.css', (req, res) => res.sendFile(resolvePath('dist/style.css'), { maxAge: '1d', })); // Serve the static folder with 1 year max-age app.use('/static', serveStatic(resolvePath('dist/static'), { maxAge: '1y', })); app.use(serveStatic(resolvePath('dist'))); } }; ================================================ FILE: server/pandoc.js ================================================ /* global window */ const { spawn } = require('child_process'); const fs = require('fs'); const tmp = require('tmp'); const user = require('./user'); const conf = require('./conf'); const outputFormats = { asciidoc: 'text/plain', context: 'application/x-latex', epub: 'application/epub+zip', epub3: 'application/epub+zip', latex: 'application/x-latex', odt: 'application/vnd.oasis.opendocument.text', pdf: 'application/pdf', rst: 'text/plain', rtf: 'application/rtf', textile: 'text/plain', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', }; const highlightStyles = [ 'pygments', 'kate', 'monochrome', 'espresso', 'zenburn', 'haddock', 'tango', ]; const readJson = (str) => { try { return JSON.parse(str); } catch (e) { return {}; } }; exports.generate = (req, res) => { let pandocError = ''; const outputFormat = Object.prototype.hasOwnProperty.call(outputFormats, req.query.format) ? req.query.format : 'pdf'; user.checkSponsor(req.query.idToken) .then((isSponsor) => { if (!isSponsor) { throw new Error('unauthorized'); } return new Promise((resolve, reject) => { tmp.file({ postfix: `.${outputFormat}`, }, (err, filePath, fd, cleanupCallback) => { if (err) { reject(err); } else { resolve({ filePath, cleanupCallback, }); } }); }); }) .then(({ filePath, cleanupCallback }) => new Promise((resolve, reject) => { const options = readJson(req.query.options); const metadata = readJson(req.query.metadata); const params = []; params.push('--pdf-engine=xelatex'); params.push('--webtex=http://chart.apis.google.com/chart?cht=tx&chf=bg,s,FFFFFF00&chco=000000&chl='); if (options.toc) { params.push('--toc'); } options.tocDepth = parseInt(options.tocDepth, 10); if (!Number.isNaN(options.tocDepth)) { params.push('--toc-depth', options.tocDepth); } options.highlightStyle = highlightStyles.includes(options.highlightStyle) ? options.highlightStyle : 'kate'; params.push('--highlight-style', options.highlightStyle); Object.keys(metadata).forEach((key) => { params.push('-M', `${key}=${metadata[key]}`); }); let finished = false; function onError(error) { finished = true; cleanupCallback(); reject(error); } const format = outputFormat === 'pdf' ? 'latex' : outputFormat; params.push('-f', 'json', '-t', format, '-o', filePath); const pandoc = spawn(conf.values.pandocPath, params, { stdio: [ 'pipe', 'ignore', 'pipe', ], }); let timeoutId = setTimeout(() => { timeoutId = null; pandoc.kill(); }, 50000); pandoc.on('error', onError); pandoc.stdin.on('error', onError); pandoc.stderr.on('data', (data) => { pandocError += `${data}`; }); pandoc.on('close', (code) => { if (!finished) { clearTimeout(timeoutId); if (!timeoutId) { res.statusCode = 408; cleanupCallback(); reject(new Error('timeout')); } else if (code) { cleanupCallback(); reject(); } else { res.set('Content-Type', outputFormats[outputFormat]); const readStream = fs.createReadStream(filePath); readStream.on('open', () => readStream.pipe(res)); readStream.on('close', () => cleanupCallback()); readStream.on('error', () => { cleanupCallback(); reject(); }); } } }); req.pipe(pandoc.stdin); })) .catch((err) => { const message = err && err.message; if (message === 'unauthorized') { res.statusCode = 401; res.end('Unauthorized.'); } else if (message === 'timeout') { res.statusCode = 408; res.end('Request timeout.'); } else { res.statusCode = 400; res.end(pandocError || 'Unknown error.'); } }); }; ================================================ FILE: server/pdf.js ================================================ /* global window,MathJax */ const { spawn } = require('child_process'); const fs = require('fs'); const tmp = require('tmp'); const user = require('./user'); const conf = require('./conf'); /* eslint-disable no-var, prefer-arrow-callback, func-names */ function waitForJavaScript() { if (window.MathJax) { // Amazon EC2: fix TeX font detection MathJax.Hub.Register.StartupHook('HTML-CSS Jax Startup', function () { var htmlCss = MathJax.OutputJax['HTML-CSS']; htmlCss.Font.checkWebFont = function (check, font, callback) { if (check.time(callback)) { return; } if (check.total === 0) { htmlCss.Font.testFont(font); setTimeout(check, 200); } else { callback(check.STATUS.OK); } }; }); MathJax.Hub.Queue(function () { window.status = 'done'; }); } else { setTimeout(function () { window.status = 'done'; }, 2000); } } /* eslint-disable no-var, prefer-arrow-callback, func-names */ const authorizedPageSizes = [ 'A3', 'A4', 'Legal', 'Letter', ]; const readJson = (str) => { try { return JSON.parse(str); } catch (e) { return {}; } }; exports.generate = (req, res) => { let wkhtmltopdfError = ''; user.checkSponsor(req.query.idToken) .then((isSponsor) => { if (!isSponsor) { throw new Error('unauthorized'); } return new Promise((resolve, reject) => { tmp.file((err, filePath, fd, cleanupCallback) => { if (err) { reject(err); } else { resolve({ filePath, cleanupCallback, }); } }); }); }) .then(({ filePath, cleanupCallback }) => new Promise((resolve, reject) => { let finished = false; function onError(err) { finished = true; cleanupCallback(); reject(err); } const options = readJson(req.query.options); const params = []; // Margins const marginTop = parseInt(`${options.marginTop}`, 10); params.push('-T', Number.isNaN(marginTop) ? 25 : marginTop); const marginRight = parseInt(`${options.marginRight}`, 10); params.push('-R', Number.isNaN(marginRight) ? 25 : marginRight); const marginBottom = parseInt(`${options.marginBottom}`, 10); params.push('-B', Number.isNaN(marginBottom) ? 25 : marginBottom); const marginLeft = parseInt(`${options.marginLeft}`, 10); params.push('-L', Number.isNaN(marginLeft) ? 25 : marginLeft); // Header if (options.headerCenter) { params.push('--header-center', `${options.headerCenter}`); } if (options.headerLeft) { params.push('--header-left', `${options.headerLeft}`); } if (options.headerRight) { params.push('--header-right', `${options.headerRight}`); } if (options.headerFontName) { params.push('--header-font-name', `${options.headerFontName}`); } if (options.headerFontSize) { params.push('--header-font-size', `${options.headerFontSize}`); } // Footer if (options.footerCenter) { params.push('--footer-center', `${options.footerCenter}`); } if (options.footerLeft) { params.push('--footer-left', `${options.footerLeft}`); } if (options.footerRight) { params.push('--footer-right', `${options.footerRight}`); } if (options.footerFontName) { params.push('--footer-font-name', `${options.footerFontName}`); } if (options.footerFontSize) { params.push('--footer-font-size', `${options.footerFontSize}`); } // Page size params.push('--page-size', !authorizedPageSizes.includes(options.pageSize) ? 'A4' : options.pageSize); // Use a temp file as wkhtmltopdf can't access /dev/stdout on Amazon EC2 for some reason params.push('--run-script', `${waitForJavaScript.toString()}waitForJavaScript()`); params.push('--window-status', 'done'); const wkhtmltopdf = spawn(conf.values.wkhtmltopdfPath, params.concat('-', filePath), { stdio: [ 'pipe', 'ignore', 'pipe', ], }); let timeoutId = setTimeout(function () { timeoutId = null; wkhtmltopdf.kill(); }, 50000); wkhtmltopdf.on('error', onError); wkhtmltopdf.stdin.on('error', onError); wkhtmltopdf.stderr.on('data', (data) => { wkhtmltopdfError += `${data}`; }); wkhtmltopdf.on('close', (code) => { if (!finished) { clearTimeout(timeoutId); if (!timeoutId) { cleanupCallback(); reject(new Error('timeout')); } else if (code) { cleanupCallback(); reject(); } else { res.set('Content-Type', 'application/pdf'); const readStream = fs.createReadStream(filePath); readStream.on('open', () => readStream.pipe(res)); readStream.on('close', () => cleanupCallback()); readStream.on('error', () => { cleanupCallback(); reject(); }); } } }); req.pipe(wkhtmltopdf.stdin); })) .catch((err) => { const message = err && err.message; if (message === 'unauthorized') { res.statusCode = 401; res.end('Unauthorized.'); } else if (message === 'timeout') { res.statusCode = 408; res.end('Request timeout.'); } else { res.statusCode = 400; res.end(wkhtmltopdfError || 'Unknown error.'); } }); }; ================================================ FILE: server/user.js ================================================ const request = require('request'); const AWS = require('aws-sdk'); const verifier = require('google-id-token-verifier'); const conf = require('./conf'); const s3Client = new AWS.S3(); const cb = (resolve, reject) => (err, res) => { if (err) { reject(err); } else { resolve(res); } }; exports.getUser = id => new Promise((resolve, reject) => { s3Client.getObject({ Bucket: conf.values.userBucketName, Key: id, }, cb(resolve, reject)); }) .then( res => JSON.parse(res.Body.toString('utf-8')), (err) => { if (err.code !== 'NoSuchKey') { throw err; } }, ); exports.putUser = (id, user) => new Promise((resolve, reject) => { s3Client.putObject({ Bucket: conf.values.userBucketName, Key: id, Body: JSON.stringify(user), }, cb(resolve, reject)); }); exports.getUserFromToken = idToken => new Promise((resolve, reject) => verifier .verify(idToken, conf.values.googleClientId, cb(resolve, reject))) .then(tokenInfo => exports.getUser(tokenInfo.sub)); exports.userInfo = (req, res) => exports.getUserFromToken(req.query.idToken) .then( user => res.send(Object.assign({ sponsorUntil: 0, }, user)), err => res .status(400) .send(err ? err.message || err.toString() : 'invalid_token'), ); exports.paypalIpn = (req, res, next) => Promise.resolve() .then(() => { const userId = req.body.custom; const paypalEmail = req.body.payer_email; const gross = parseFloat(req.body.mc_gross); let sponsorUntil; if (gross === 5) { sponsorUntil = Date.now() + (3 * 31 * 24 * 60 * 60 * 1000); // 3 months } else if (gross === 15) { sponsorUntil = Date.now() + (366 * 24 * 60 * 60 * 1000); // 1 year } else if (gross === 25) { sponsorUntil = Date.now() + (2 * 366 * 24 * 60 * 60 * 1000); // 2 years } else if (gross === 50) { sponsorUntil = Date.now() + (5 * 366 * 24 * 60 * 60 * 1000); // 5 years } if ( req.body.receiver_email !== conf.values.paypalReceiverEmail || req.body.payment_status !== 'Completed' || req.body.mc_currency !== 'USD' || (req.body.txn_type !== 'web_accept' && req.body.txn_type !== 'subscr_payment') || !userId || !sponsorUntil ) { // Ignoring PayPal IPN return res.end(); } // Processing PayPal IPN req.body.cmd = '_notify-validate'; return new Promise((resolve, reject) => request.post({ uri: conf.values.paypalUri, form: req.body, }, (err, response, body) => { if (err) { reject(err); } else if (body !== 'VERIFIED') { reject(new Error('PayPal IPN unverified')); } else { resolve(); } })) .then(() => exports.putUser(userId, { paypalEmail, sponsorUntil, })) .then(() => res.end()); }) .catch(next); exports.checkSponsor = (idToken) => { if (!conf.publicValues.allowSponsorship) { return Promise.resolve(true); } if (!idToken) { return Promise.resolve(false); } return exports.getUserFromToken(idToken) .then(userInfo => userInfo && userInfo.sponsorUntil > Date.now(), () => false); }; ================================================ FILE: src/components/App.vue ================================================ ================================================ FILE: src/components/ButtonBar.vue ================================================ ================================================ FILE: src/components/CodeEditor.vue ================================================ ================================================ FILE: src/components/ContextMenu.vue ================================================ ================================================ FILE: src/components/Editor.vue ================================================ ================================================ FILE: src/components/Explorer.vue ================================================ ================================================ FILE: src/components/ExplorerNode.vue ================================================ ================================================ FILE: src/components/FindReplace.vue ================================================ ================================================ FILE: src/components/Layout.vue ================================================ ================================================ FILE: src/components/Modal.vue ================================================ ================================================ FILE: src/components/NavigationBar.vue ================================================ ================================================ FILE: src/components/Notification.vue ================================================ ================================================ FILE: src/components/Preview.vue ================================================ ================================================ FILE: src/components/SideBar.vue ================================================ ================================================ FILE: src/components/SplashScreen.vue ================================================ ================================================ FILE: src/components/StatusBar.vue ================================================ ================================================ FILE: src/components/Toc.vue ================================================ ================================================ FILE: src/components/Tour.vue ================================================ ================================================ FILE: src/components/UserImage.vue ================================================ ================================================ FILE: src/components/UserName.vue ================================================ ================================================ FILE: src/components/common/EditorClassApplier.js ================================================ 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; } } ================================================ FILE: src/components/common/PreviewClassApplier.js ================================================ 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; } } ================================================ FILE: src/components/common/vueGlobals.js ================================================ 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)); ================================================ FILE: src/components/gutters/Comment.vue ================================================ ================================================ FILE: src/components/gutters/CommentList.vue ================================================ ================================================ FILE: src/components/gutters/CurrentDiscussion.vue ================================================ ================================================ FILE: src/components/gutters/EditorNewDiscussionButton.vue ================================================ ================================================ FILE: src/components/gutters/NewComment.vue ================================================ ================================================ FILE: src/components/gutters/PreviewNewDiscussionButton.vue ================================================ ================================================ FILE: src/components/gutters/StickyComment.vue ================================================ ================================================ FILE: src/components/menus/HistoryMenu.vue ================================================ ================================================ FILE: src/components/menus/ImportExportMenu.vue ================================================ ================================================ FILE: src/components/menus/MainMenu.vue ================================================ ================================================ FILE: src/components/menus/PublishMenu.vue ================================================ ================================================ FILE: src/components/menus/SyncMenu.vue ================================================ ================================================ FILE: src/components/menus/WorkspaceBackupMenu.vue ================================================ ================================================ FILE: src/components/menus/WorkspacesMenu.vue ================================================ ================================================ FILE: src/components/menus/common/MenuEntry.vue ================================================ ================================================ FILE: src/components/modals/AboutModal.vue ================================================ ================================================ FILE: src/components/modals/AccountManagementModal.vue ================================================ ================================================ FILE: src/components/modals/BadgeManagementModal.vue ================================================ ================================================ FILE: src/components/modals/FilePropertiesModal.vue ================================================ ================================================ FILE: src/components/modals/HtmlExportModal.vue ================================================ ================================================ FILE: src/components/modals/ImageModal.vue ================================================ ================================================ FILE: src/components/modals/LinkModal.vue ================================================ ================================================ FILE: src/components/modals/PandocExportModal.vue ================================================ ================================================ FILE: src/components/modals/PdfExportModal.vue ================================================ ================================================ FILE: src/components/modals/PublishManagementModal.vue ================================================ ================================================ FILE: src/components/modals/SettingsModal.vue ================================================ ================================================ FILE: src/components/modals/SponsorModal.vue ================================================ ================================================ FILE: src/components/modals/SyncManagementModal.vue ================================================ ================================================ FILE: src/components/modals/TemplatesModal.vue ================================================ ================================================ FILE: src/components/modals/WorkspaceManagementModal.vue ================================================ ================================================ FILE: src/components/modals/common/FormEntry.vue ================================================ ================================================ FILE: src/components/modals/common/ModalInner.vue ================================================ ================================================ FILE: src/components/modals/common/Tab.vue ================================================ ================================================ FILE: src/components/modals/common/modalTemplate.js ================================================ 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; }; ================================================ FILE: src/components/modals/providers/BloggerPagePublishModal.vue ================================================ ================================================ FILE: src/components/modals/providers/BloggerPublishModal.vue ================================================ ================================================ FILE: src/components/modals/providers/CouchdbCredentialsModal.vue ================================================ ================================================ FILE: src/components/modals/providers/CouchdbWorkspaceModal.vue ================================================ ================================================ FILE: src/components/modals/providers/DropboxAccountModal.vue ================================================ ================================================ FILE: src/components/modals/providers/DropboxPublishModal.vue ================================================ ================================================ FILE: src/components/modals/providers/DropboxSaveModal.vue ================================================ ================================================ FILE: src/components/modals/providers/GistPublishModal.vue ================================================ ================================================ FILE: src/components/modals/providers/GistSyncModal.vue ================================================ ================================================ FILE: src/components/modals/providers/GithubAccountModal.vue ================================================ ================================================ FILE: src/components/modals/providers/GithubOpenModal.vue ================================================ ================================================ FILE: src/components/modals/providers/GithubPublishModal.vue ================================================ ================================================ FILE: src/components/modals/providers/GithubSaveModal.vue ================================================ ================================================ FILE: src/components/modals/providers/GithubWorkspaceModal.vue ================================================ ================================================ FILE: src/components/modals/providers/GitlabAccountModal.vue ================================================ ================================================ FILE: src/components/modals/providers/GitlabOpenModal.vue ================================================ ================================================ FILE: src/components/modals/providers/GitlabPublishModal.vue ================================================ ================================================ FILE: src/components/modals/providers/GitlabSaveModal.vue ================================================ ================================================ FILE: src/components/modals/providers/GitlabWorkspaceModal.vue ================================================ ================================================ FILE: src/components/modals/providers/GoogleDriveAccountModal.vue ================================================ ================================================ FILE: src/components/modals/providers/GoogleDrivePublishModal.vue ================================================ ================================================ FILE: src/components/modals/providers/GoogleDriveSaveModal.vue ================================================ ================================================ FILE: src/components/modals/providers/GoogleDriveWorkspaceModal.vue ================================================ ================================================ FILE: src/components/modals/providers/GooglePhotoModal.vue ================================================ ================================================ FILE: src/components/modals/providers/WordpressPublishModal.vue ================================================ ================================================ FILE: src/components/modals/providers/ZendeskAccountModal.vue ================================================ ================================================ FILE: src/components/modals/providers/ZendeskPublishModal.vue ================================================ ================================================ FILE: src/data/constants.js ================================================ const origin = `${window.location.protocol}//${window.location.host}`; export default { cleanTrashAfter: 7 * 24 * 60 * 60 * 1000, // 7 days origin, oauth2RedirectUri: `${origin}/oauth2/callback`, types: [ 'contentState', 'syncedContent', 'content', 'file', 'folder', 'syncLocation', 'publishLocation', 'data', ], localStorageDataIds: [ 'workspaces', 'settings', 'layoutSettings', 'tokens', 'badgeCreations', 'serverConf', ], textMaxLength: 250000, defaultName: 'Untitled', }; ================================================ FILE: src/data/defaults/defaultLayoutSettings.js ================================================ export default () => ({ showNavigationBar: true, showEditor: true, showSidePreview: true, showStatusBar: true, showSideBar: false, showExplorer: false, scrollSync: true, focusMode: false, findCaseSensitive: false, findUseRegexp: false, sideBarPanel: 'menu', welcomeTourFinished: false, }); ================================================ FILE: src/data/defaults/defaultLocalSettings.js ================================================ export default () => ({ welcomeFileHashes: {}, filePropertiesTab: '', htmlExportTemplate: 'styledHtml', pdfExportTemplate: 'styledHtml', pandocExportFormat: 'pdf', googleDriveRestrictedAccess: false, googleDriveFolderId: '', googleDriveWorkspaceFolderId: '', googleDrivePublishFormat: 'markdown', googleDrivePublishTemplate: 'styledHtml', bloggerBlogUrl: '', bloggerPublishTemplate: 'plainHtml', dropboxRestrictedAccess: false, dropboxPublishTemplate: 'styledHtml', githubRepoFullAccess: false, githubRepoUrl: '', githubWorkspaceRepoUrl: '', githubPublishTemplate: 'jekyllSite', gistIsPublic: false, gistPublishTemplate: 'plainText', gitlabServerUrl: '', gitlabApplicationId: '', gitlabProjectUrl: '', gitlabWorkspaceProjectUrl: '', gitlabPublishTemplate: 'plainText', wordpressDomain: '', wordpressPublishTemplate: 'plainHtml', zendeskSiteUrl: '', zendeskClientId: '', zendescPublishSectionId: '', zendescPublishLocale: '', zendeskPublishTemplate: 'plainHtml', }); ================================================ FILE: src/data/defaults/defaultSettings.yml ================================================ # light or dark colorTheme: light # Adjust font size in editor and preview fontSizeFactor: 1 # Adjust maximum text width in editor and preview maxWidthFactor: 1 # Auto-sync frequency (in ms). Minimum is 60000. autoSyncEvery: 90000 # Editor settings editor: # Automatic list numbering listAutoNumber: true # Display images in the editor inlineImages: true # Use monospaced font only monospacedFontOnly: false # Keyboard shortcuts # See https://craig.is/killing/mice shortcuts: mod+s: sync mod+f: find mod+alt+f: replace mod+g: replace mod+shift+b: bold mod+shift+c: clist mod+shift+k: code mod+shift+h: heading mod+shift+r: hr mod+shift+g: image mod+shift+i: italic mod+shift+l: link mod+shift+o: olist mod+shift+q: quote mod+shift+s: strikethrough mod+shift+t: table mod+shift+u: ulist '= = > space': method: expand params: - '==> ' - '⇒ ' '< = = space': method: expand params: - '<== ' - '⇐ ' # Options passed to wkhtmltopdf # See https://wkhtmltopdf.org/usage/wkhtmltopdf.txt wkhtmltopdf: marginTop: 25 marginRight: 25 marginBottom: 25 marginLeft: 25 # A3, A4, Legal or Letter pageSize: A4 # Options passed to pandoc # See https://pandoc.org/MANUAL.html pandoc: highlightStyle: kate toc: true tocDepth: 3 # HTML to Markdown converter options # See https://github.com/domchristie/turndown turndown: headingStyle: atx hr: ---------- bulletListMarker: '-' codeBlockStyle: fenced fence: '```' emDelimiter: _ strongDelimiter: '**' linkStyle: inlined linkReferenceStyle: full # GitHub/GitLab commit messages git: createFileMessage: '{{path}} created from https://stackedit.io/' updateFileMessage: '{{path}} updated from https://stackedit.io/' deleteFileMessage: '{{path}} deleted from https://stackedit.io/' # Default content for new files newFileContent: | > Written with [StackEdit](https://stackedit.io/). # Default properties for new files newFileProperties: | # extensions: # preset: gfm ================================================ FILE: src/data/defaults/defaultWorkspaces.js ================================================ export default () => ({ main: { id: 'main', name: 'Main workspace', // The rest will be filled by the workspace/workspacesById getter }, }); ================================================ FILE: src/data/empties/emptyContent.js ================================================ export default (id = null) => ({ id, type: 'content', text: '\n', properties: '\n', discussions: {}, comments: {}, hash: 0, }); ================================================ FILE: src/data/empties/emptyContentState.js ================================================ export default (id = null) => ({ id, type: 'contentState', selectionStart: 0, selectionEnd: 0, scrollPosition: null, hash: 0, }); ================================================ FILE: src/data/empties/emptyFile.js ================================================ export default (id = null) => ({ id, type: 'file', name: '', parentId: null, hash: 0, }); ================================================ FILE: src/data/empties/emptyFolder.js ================================================ export default (id = null) => ({ id, type: 'folder', name: '', parentId: null, hash: 0, }); ================================================ FILE: src/data/empties/emptyPublishLocation.js ================================================ export default (id = null) => ({ id, type: 'publishLocation', providerId: null, fileId: null, templateId: null, hash: 0, }); ================================================ FILE: src/data/empties/emptySyncLocation.js ================================================ export default (id = null) => ({ id, type: 'syncLocation', providerId: null, fileId: null, hash: 0, }); ================================================ FILE: src/data/empties/emptySyncedContent.js ================================================ export default (id = null) => ({ id, type: 'syncedContent', historyData: {}, syncHistory: {}, v: 0, hash: 0, }); ================================================ FILE: src/data/empties/emptyTemplateHelpers.js ================================================ /* Add your custom Handlebars helpers here. For example: Handlebars.registerHelper('transform', function (options) { var result = options.fn(this); return new Handlebars.SafeString( result.replace(/]*>/g, '
')
  );
});

Then use the helper in your template:

{{#transform}}{{{files.0.content.html}}}{{/transform}}
*/



================================================
FILE: src/data/empties/emptyTemplateValue.html
================================================




================================================
FILE: src/data/faq.md
================================================
**Where is my data stored?**

If your workspace is not synced, your files are stored inside your browser and nowhere else.

We recommend syncing your workspace to make sure files won't be lost in case your browser data is cleared. Self-hosted CouchDB or GitLab backends are well suited for privacy.

**Can StackEdit access my data without telling me?**

StackEdit is a browser-based application. The access tokens issued by Google, Dropbox, GitHub... are stored in your browser and are not sent to any kind of backend or 3^rd^ party so your data won't be accessed by anyone.


================================================
FILE: src/data/features.js
================================================
class Feature {
  constructor(id, badgeName, description, children = null) {
    this.id = id;
    this.badgeName = badgeName;
    this.description = description;
    this.children = children;
  }

  toBadge(badgeCreations) {
    const children = this.children
      ? this.children.map(child => child.toBadge(badgeCreations))
      : null;
    return {
      featureId: this.id,
      name: this.badgeName,
      description: this.description,
      children,
      isEarned: children
        ? children.every(child => child.isEarned)
        : !!badgeCreations[this.id],
      hasSomeEarned: children && children.some(child => child.isEarned),
    };
  }
}

export default [
  new Feature(
    'navigationBar',
    'Nav bar expert',
    'Master the navigation bar by formatting some Markdown and renaming the current file.',
    [
      new Feature(
        'formatButtons',
        'Formatter',
        'Use the format buttons to change formatting in your Markdown file.',
      ),
      new Feature(
        'editCurrentFileName',
        'Renamer',
        'Use the name field in the navigation bar to rename the current file.',
      ),
      new Feature(
        'toggleExplorer',
        'Explorer toggler',
        'Use the navigation bar to toggle the explorer.',
      ),
      new Feature(
        'toggleSideBar',
        'Side bar toggler',
        'Use the navigation bar to toggle the side bar.',
      ),
    ],
  ),
  new Feature(
    'explorer',
    'Explorer',
    'Use the file explorer to manage files and folders in your workspace.',
    [
      new Feature(
        'createFile',
        'File creator',
        'Use the file explorer to create a new file in your workspace.',
      ),
      new Feature(
        'switchFile',
        'File switcher',
        'Use the file explorer to switch from one file to another in your workspace.',
      ),
      new Feature(
        'createFolder',
        'Folder creator',
        'Use the file explorer to create a new folder in your workspace.',
      ),
      new Feature(
        'moveFile',
        'File mover',
        'Drag a file in the file explorer to move it in another folder.',
      ),
      new Feature(
        'moveFolder',
        'Folder mover',
        'Drag a folder in the file explorer to move it in another folder.',
      ),
      new Feature(
        'renameFile',
        'File renamer',
        'Use the file explorer to rename a file in your workspace.',
      ),
      new Feature(
        'renameFolder',
        'Folder renamer',
        'Use the file explorer to rename a folder in your workspace.',
      ),
      new Feature(
        'removeFile',
        'File remover',
        'Use the file explorer to remove a file in your workspace.',
      ),
      new Feature(
        'removeFolder',
        'Folder remover',
        'Use the file explorer to remove a folder in your workspace.',
      ),
    ],
  ),
  new Feature(
    'buttonBar',
    'Button bar expert',
    'Use the button bar to customize the editor layout and to toggle features.',
    [
      new Feature(
        'toggleNavigationBar',
        'Navigation bar toggler',
        'Use the button bar to toggle the navigation bar.',
      ),
      new Feature(
        'toggleSidePreview',
        'Side preview toggler',
        'Use the button bar to toggle the side preview.',
      ),
      new Feature(
        'toggleEditor',
        'Editor toggler',
        'Use the button bar to toggle the editor.',
      ),
      new Feature(
        'toggleFocusMode',
        'Focused',
        'Use the button bar to toggle the focus mode. This mode keeps the caret vertically centered while typing.',
      ),
      new Feature(
        'toggleScrollSync',
        'Scroll sync toggler',
        'Use the button bar to toggle the scroll sync feature. This feature links the editor and the preview scrollbars.',
      ),
      new Feature(
        'toggleStatusBar',
        'Status bar toggler',
        'Use the button bar to toggle the status bar.',
      ),
    ],
  ),
  new Feature(
    'signIn',
    'Signed in',
    'Sign in with Google, sync your main workspace and unlock functionalities.',
    [
      new Feature(
        'syncMainWorkspace',
        'Main workspace synced',
        'Sign in with Google to sync your main workspace with your Google Drive app data folder.',
      ),
      new Feature(
        'sponsor',
        'Sponsor',
        'Sign in with Google and sponsor StackEdit to unlock PDF and Pandoc exports.',
      ),
    ],
  ),
  new Feature(
    'workspaces',
    'Workspace expert',
    'Use the workspace menu to create all kinds of workspaces and to manage them.',
    [
      new Feature(
        'addCouchdbWorkspace',
        'CouchDB workspace creator',
        'Use the workspace menu to create a CouchDB workspace.',
      ),
      new Feature(
        'addGithubWorkspace',
        'GitHub workspace creator',
        'Use the workspace menu to create a GitHub workspace.',
      ),
      new Feature(
        'addGitlabWorkspace',
        'GitLab workspace creator',
        'Use the workspace menu to create a GitLab workspace.',
      ),
      new Feature(
        'addGoogleDriveWorkspace',
        'Google Drive workspace creator',
        'Use the workspace menu to create a Google Drive workspace.',
      ),
      new Feature(
        'renameWorkspace',
        'Workspace renamer',
        'Use the "Manage workspaces" dialog to rename a workspace.',
      ),
      new Feature(
        'removeWorkspace',
        'Workspace remover',
        'Use the "Manage workspaces" dialog to remove a workspace locally.',
      ),
    ],
  ),
  new Feature(
    'manageAccounts',
    'Account manager',
    'Link all kinds of external accounts and use the "Accounts" dialog to manage them.',
    [
      new Feature(
        'addBloggerAccount',
        'Blogger user',
        'Link your Blogger account to StackEdit.',
      ),
      new Feature(
        'addDropboxAccount',
        'Dropbox user',
        'Link your Dropbox account to StackEdit.',
      ),
      new Feature(
        'addGitHubAccount',
        'GitHub user',
        'Link your GitHub account to StackEdit.',
      ),
      new Feature(
        'addGitLabAccount',
        'GitLab user',
        'Link your GitLab account to StackEdit.',
      ),
      new Feature(
        'addGoogleDriveAccount',
        'Google Drive user',
        'Link your Google Drive account to StackEdit.',
      ),
      new Feature(
        'addGooglePhotosAccount',
        'Google Photos user',
        'Link your Google Photos account to StackEdit.',
      ),
      new Feature(
        'addWordpressAccount',
        'WordPress user',
        'Link your WordPress account to StackEdit.',
      ),
      new Feature(
        'addZendeskAccount',
        'Zendesk user',
        'Link your Zendesk account to StackEdit.',
      ),
      new Feature(
        'removeAccount',
        'Revoker',
        'Use the "Accounts" dialog to remove access to an external account.',
      ),
    ],
  ),
  new Feature(
    'syncFiles',
    'File synchronizer',
    'Master the "Synchronize" menu by opening and saving files with all kinds of external accounts.',
    [
      new Feature(
        'openFromDropbox',
        'Dropbox reader',
        'Use the "Synchronize" menu to open a file from your Dropbox account.',
      ),
      new Feature(
        'saveOnDropbox',
        'Dropbox writer',
        'Use the "Synchronize" menu to save a file in your Dropbox account.',
      ),
      new Feature(
        'openFromGithub',
        'GitHub reader',
        'Use the "Synchronize" menu to open a file from a GitHub repository.',
      ),
      new Feature(
        'saveOnGithub',
        'GitHub writer',
        'Use the "Synchronize" menu to save a file in a GitHub repository.',
      ),
      new Feature(
        'saveOnGist',
        'Gist writer',
        'Use the "Synchronize" menu to save a file in a Gist.',
      ),
      new Feature(
        'openFromGitlab',
        'GitLab reader',
        'Use the "Synchronize" menu to open a file from a GitLab repository.',
      ),
      new Feature(
        'saveOnGitlab',
        'GitLab writer',
        'Use the "Synchronize" menu to save a file in a GitLab repository.',
      ),
      new Feature(
        'openFromGoogleDrive',
        'Google Drive reader',
        'Use the "Synchronize" menu to open a file from your Google Drive account.',
      ),
      new Feature(
        'saveOnGoogleDrive',
        'Google Drive writer',
        'Use the "Synchronize" menu to save a file in your Google Drive account.',
      ),
      new Feature(
        'triggerSync',
        'Sync trigger',
        'Use the "Synchronize" menu or the navigation bar to manually trigger synchronization.',
      ),
      new Feature(
        'syncMultipleLocations',
        'Multi-sync',
        'Use the "Synchronize" menu to synchronize a file with multiple external locations.',
      ),
      new Feature(
        'removeSyncLocation',
        'Desynchronizer',
        'Use the "File synchronization" dialog to remove a sync location.',
      ),
    ],
  ),
  new Feature(
    'publishFiles',
    'File publisher',
    'Master the "Publish" menu by publishing files to all kinds of external accounts.',
    [
      new Feature(
        'publishToBlogger',
        'Blogger publisher',
        'Use the "Publish" menu to publish a Blogger article.',
      ),
      new Feature(
        'publishToBloggerPage',
        'Blogger Page publisher',
        'Use the "Publish" menu to publish a Blogger page.',
      ),
      new Feature(
        'publishToDropbox',
        'Dropbox publisher',
        'Use the "Publish" menu to publish a file to your Dropbox account.',
      ),
      new Feature(
        'publishToGithub',
        'GitHub publisher',
        'Use the "Publish" menu to publish a file to a GitHub repository.',
      ),
      new Feature(
        'publishToGist',
        'Gist publisher',
        'Use the "Publish" menu to publish a file to a Gist.',
      ),
      new Feature(
        'publishToGitlab',
        'GitLab publisher',
        'Use the "Publish" menu to publish a file to a GitLab repository.',
      ),
      new Feature(
        'publishToGoogleDrive',
        'Google Drive publisher',
        'Use the "Publish" menu to publish a file to your Google Drive account.',
      ),
      new Feature(
        'publishToWordPress',
        'WordPress publisher',
        'Use the "Publish" menu to publish a WordPress article.',
      ),
      new Feature(
        'publishToZendesk',
        'Zendesk publisher',
        'Use the "Publish" menu to publish a Zendesk Help Center article.',
      ),
      new Feature(
        'triggerPublish',
        'Publication reviser',
        'Use the "Publish" menu or the navigation bar to manually update publications.',
      ),
      new Feature(
        'publishMultipleLocations',
        'Multi-publication',
        'Use the "Publish" menu to publish a file to multiple external locations.',
      ),
      new Feature(
        'removePublishLocation',
        'Unpublisher',
        'Use the "File publication" dialog to remove a publish location.',
      ),
    ],
  ),
  new Feature(
    'manageHistory',
    'Historian',
    'Use the "File history" menu to see version history and restore old versions of the current file.',
    [
      new Feature(
        'restoreVersion',
        'Restorer',
        'Use the "File history" menu to restore an old version of the current file.',
      ),
      new Feature(
        'chooseHistory',
        'History chooser',
        'Select a different history for a file that is synced with multiple external locations.',
      ),
    ],
  ),
  new Feature(
    'manageProperties',
    'Property expert',
    'Use the "File properties" dialog to change properties for the current file.',
    [
      new Feature(
        'setMetadata',
        'Metadata setter',
        'Use the "File properties" dialog to set metadata for the current file.',
      ),
      new Feature(
        'changePreset',
        'Preset changer',
        'Use the "File properties" dialog to change the Markdown engine preset.',
      ),
      new Feature(
        'changeExtension',
        'Extension expert',
        'Use the "File properties" dialog to enable, disable or configure Markdown engine extensions.',
      ),
    ],
  ),
  new Feature(
    'comment',
    'Comment expert',
    'Start and remove discussions, add and remove comments.',
    [
      new Feature(
        'createDiscussion',
        'Discussion starter',
        'Use the "comment" button to start a new discussion.',
      ),
      new Feature(
        'addComment',
        'Commenter',
        'Use the discussion gutter to add a comment to an existing discussion.',
      ),
      new Feature(
        'removeComment',
        'Moderator',
        'Use the discussion gutter to remove a comment in a discussion.',
      ),
      new Feature(
        'removeDiscussion',
        'Discussion closer',
        'Use the discussion gutter to remove a discussion.',
      ),
    ],
  ),
  new Feature(
    'importExport',
    'Import/export',
    'Use the "Import/export" menu to import and export files.',
    [
      new Feature(
        'importMarkdown',
        'Markdown importer',
        'Use the "Import/export" menu to import a Markdown file from disk.',
      ),
      new Feature(
        'exportMarkdown',
        'Markdown exporter',
        'Use the "Import/export" menu to export a Markdown file to disk.',
      ),
      new Feature(
        'importHtml',
        'HTML importer',
        'Use the "Import/export" menu to import an HTML file from disk and convert it to Markdown.',
      ),
      new Feature(
        'exportHtml',
        'HTML exporter',
        'Use the "Import/export" menu to export a file to disk as an HTML file using a Handlebars template.',
      ),
      new Feature(
        'exportPdf',
        'PDF exporter',
        'Use the "Import/export" menu to export a file to disk as a PDF file.',
      ),
      new Feature(
        'exportPandoc',
        'Pandoc exporter',
        'Use the "Import/export" menu to export a file to disk using Pandoc.',
      ),
    ],
  ),
  new Feature(
    'manageSettings',
    'Settings expert',
    'Use the "Settings" dialog to tweak the application behaviors and change keyboard shortcuts.',
    [
      new Feature(
        'changeSettings',
        'Tweaker',
        'Use the "Settings" dialog to tweak the application behaviors.',
      ),
      new Feature(
        'changeShortcuts',
        'Shortcut editor',
        'Use the "Settings" dialog to change keyboard shortcuts.',
      ),
    ],
  ),
  new Feature(
    'manageTemplates',
    'Template expert',
    'Use the "Templates" dialog to create, remove or modify Handlebars templates.',
    [
      new Feature(
        'addTemplate',
        'Template creator',
        'Use the "Templates" dialog to create a Handlebars template.',
      ),
      new Feature(
        'removeTemplate',
        'Template remover',
        'Use the "Templates" dialog to remove a Handlebars template.',
      ),
    ],
  ),
];


================================================
FILE: src/data/markdownSample.md
================================================
Headers
---------------------------

# Header 1

## Header 2

### Header 3



Styling
---------------------------

*Emphasize* _emphasize_

**Strong** __strong__

==Marked text.==

~~Mistaken text.~~

> Quoted text.

H~2~O is a liquid.

2^10^ is 1024.



Lists
---------------------------

- Item
  * Item
    + Item

1. Item 1
2. Item 2
3. Item 3

- [ ] Incomplete item
- [x] Complete item



Links
---------------------------

A [link](http://example.com).

An image: ![Alt](img.jpg)

A sized image: ![Alt](img.jpg =60x50)



Code
---------------------------

Some `inline code`.

```
// A code block
var foo = 'bar';
```

```javascript
// An highlighted block
var foo = 'bar';
```



Tables
---------------------------

Item     | Value
-------- | -----
Computer | $1600
Phone    | $12
Pipe     | $1


| Column 1 | Column 2      |
|:--------:| -------------:|
| centered | right-aligned |



Definition lists
---------------------------

Markdown
:  Text-to-HTML conversion tool

Authors
:  John
:  Luke



Footnotes
---------------------------

Some text with a footnote.[^1]

[^1]: The footnote.



Abbreviations
---------------------------

Markdown converts text to HTML.

*[HTML]: HyperText Markup Language



LaTeX math
---------------------------

The Gamma function satisfying $\Gamma(n) = (n-1)!\quad\forall
n\in\mathbb N$ is via the Euler integral

$$
\Gamma(z) = \int_0^\infty t^{z-1}e^{-t}dt\,.
$$


================================================
FILE: src/data/pagedownButtons.js
================================================
export default [{
}, {
  method: 'bold',
  title: 'Bold',
  icon: 'format-bold',
}, {
  method: 'italic',
  title: 'Italic',
  icon: 'format-italic',
}, {
  method: 'heading',
  title: 'Heading',
  icon: 'format-size',
}, {
  method: 'strikethrough',
  title: 'Strikethrough',
  icon: 'format-strikethrough',
}, {
}, {
  method: 'ulist',
  title: 'Unordered list',
  icon: 'format-list-bulleted',
}, {
  method: 'olist',
  title: 'Ordered list',
  icon: 'format-list-numbers',
}, {
  method: 'clist',
  title: 'Check list',
  icon: 'format-list-checks',
}, {
}, {
  method: 'quote',
  title: 'Blockquote',
  icon: 'format-quote-close',
}, {
  method: 'code',
  title: 'Code',
  icon: 'code-tags',
}, {
  method: 'table',
  title: 'Table',
  icon: 'table',
}, {
  method: 'link',
  title: 'Link',
  icon: 'link-variant',
}, {
  method: 'image',
  title: 'Image',
  icon: 'file-image',
}];


================================================
FILE: src/data/presets.js
================================================
const zero = {
  // Markdown extensions
  markdown: {
    abbr: false,
    breaks: false,
    deflist: false,
    del: false,
    fence: false,
    footnote: false,
    imgsize: false,
    linkify: false,
    mark: false,
    sub: false,
    sup: false,
    table: false,
    tasklist: false,
    typographer: false,
  },
  // Emoji extension
  emoji: {
    enabled: false,
    // Enable shortcuts like :) :-(
    shortcuts: false,
  },
  /*
  ABC Notation extension
  Render abc-notation code blocks to music sheets
  See https://abcjs.net/
  */
  abc: {
    enabled: false,
  },
  /*
  Katex extension
  Render LaTeX mathematical expressions using:
    $...$ for inline formulas
    $$...$$ for displayed formulas.
  See https://math.meta.stackexchange.com/questions/5020
  */
  katex: {
    enabled: false,
  },
  /*
  Mermaid extension
  Convert code blocks starting with ```mermaid
  into diagrams and flowcharts.
  See https://mermaidjs.github.io/
  */
  mermaid: {
    enabled: false,
  },
};

export default {
  zero: [zero],
  commonmark: [zero, {
    markdown: {
      fence: true,
    },
  }],
  gfm: [zero, {
    markdown: {
      breaks: true,
      del: true,
      fence: true,
      linkify: true,
      table: true,
      tasklist: true,
    },
    emoji: {
      enabled: true,
    },
  }],
  default: [zero, {
    markdown: {
      abbr: true,
      breaks: true,
      deflist: true,
      del: true,
      fence: true,
      footnote: true,
      imgsize: true,
      linkify: true,
      mark: true,
      sub: true,
      sup: true,
      table: true,
      tasklist: true,
      typographer: true,
    },
    emoji: {
      enabled: true,
    },
    katex: {
      enabled: true,
    },
    mermaid: {
      enabled: true,
    },
    abc: {
      enabled: true,
    },
  }],
};


================================================
FILE: src/data/simpleModals.js
================================================
const simpleModal = (contentHtml, rejectText, resolveText) => ({
  contentHtml: typeof contentHtml === 'function' ? contentHtml : () => contentHtml,
  rejectText,
  resolveText,
});

/* eslint sort-keys: "error" */
export default {
  commentDeletion: simpleModal(
    '

You are about to delete a comment. Are you sure?

', 'No', 'Yes, delete', ), discussionDeletion: simpleModal( '

You are about to delete a discussion. Are you sure?

', 'No', 'Yes, delete', ), fileRestoration: simpleModal( '

You are about to revert some changes. Are you sure?

', 'No', 'Yes, revert', ), folderDeletion: simpleModal( config => `

You are about to delete the folder ${config.item.name}. Its files will be moved to Trash. Are you sure?

`, 'No', 'Yes, delete', ), pathConflict: simpleModal( config => `

${config.item.name} already exists. Do you want to add a suffix?

`, 'No', 'Yes, add suffix', ), paymentSuccess: simpleModal( '

Thank you for your payment!

Your sponsorship will be active in a minute.

', 'Ok', ), providerRedirection: simpleModal( config => `

You are about to navigate to the ${config.name} authorization page.

`, 'Cancel', 'Ok, go on', ), removeWorkspace: simpleModal( '

You are about to remove a workspace locally. Are you sure?

', 'No', 'Yes, remove', ), reset: simpleModal( '

This will clean all your workspaces locally. Are you sure?

', 'No', 'Yes, clean', ), signInForComment: simpleModal( `

You have to sign in with Google to start commenting.

`, 'Cancel', 'Ok, sign in', ), signInForSponsorship: simpleModal( `

You have to sign in with Google to sponsor.

`, 'Cancel', 'Ok, sign in', ), sponsorOnly: simpleModal( '

This feature is restricted to sponsors as it relies on server resources.

', 'Ok, I understand', ), stripName: simpleModal( config => `

${config.item.name} contains illegal characters. Do you want to strip them?

`, 'No', 'Yes, strip', ), tempFileDeletion: simpleModal( config => `

You are about to permanently delete the temporary file ${config.item.name}. Are you sure?

`, 'No', 'Yes, delete', ), tempFolderDeletion: simpleModal( '

You are about to permanently delete all the temporary files. Are you sure?

', 'No', 'Yes, delete all', ), trashDeletion: simpleModal( '

Files in the trash are automatically deleted after 7 days of inactivity.

', 'Ok', ), unauthorizedName: simpleModal( config => `

${config.item.name} is an unauthorized name.

`, 'Ok', ), workspaceGoogleRedirection: simpleModal( '

StackEdit needs full Google Drive access to open this workspace.

', 'Cancel', 'Ok, grant', ), }; ================================================ FILE: src/data/templates/jekyllSiteTemplate.html ================================================ --- {{{files.0.content.yamlProperties}}} --- {{{files.0.content.html}}} ================================================ FILE: src/data/templates/plainHtmlTemplate.html ================================================ {{{files.0.content.html}}} ================================================ FILE: src/data/templates/styledHtmlTemplate.html ================================================ {{files.0.name}} {{#if pdf}} {{else}} {{/if}}
{{{files.0.content.html}}}
================================================ FILE: src/data/templates/styledHtmlWithTocTemplate.html ================================================ {{files.0.name}} {{#if pdf}} {{else}} {{/if}}
{{#tocToHtml files.0.content.toc 2}}{{/tocToHtml}}
{{{files.0.content.html}}}
================================================ FILE: src/data/welcomeFile.md ================================================ # Welcome to StackEdit! Hi! I'm your first Markdown file in **StackEdit**. If you want to learn about StackEdit, you can read me. If you want to play with Markdown, you can edit me. Once you have finished with me, you can create new files by opening the **file explorer** on the left corner of the navigation bar. # Files StackEdit stores your files in your browser, which means all your files are automatically saved locally and are accessible **offline!** ## Create files and folders The file explorer is accessible using the button in left corner of the navigation bar. You can create a new file by clicking the **New file** button in the file explorer. You can also create folders by clicking the **New folder** button. ## Switch to another file All your files and folders are presented as a tree in the file explorer. You can switch from one to another by clicking a file in the tree. ## Rename a file You can rename the current file by clicking the file name in the navigation bar or by clicking the **Rename** button in the file explorer. ## Delete a file You can delete the current file by clicking the **Remove** button in the file explorer. The file will be moved into the **Trash** folder and automatically deleted after 7 days of inactivity. ## Export a file You can export the current file by clicking **Export to disk** in the menu. You can choose to export the file as plain Markdown, as HTML using a Handlebars template or as a PDF. # Synchronization Synchronization is one of the biggest features of StackEdit. It enables you to synchronize any file in your workspace with other files stored in your **Google Drive**, your **Dropbox** and your **GitHub** accounts. This allows you to keep writing on other devices, collaborate with people you share the file with, integrate easily into your workflow... The synchronization mechanism takes place every minute in the background, downloading, merging, and uploading file modifications. There are two types of synchronization and they can complement each other: - The workspace synchronization will sync all your files, folders and settings automatically. This will allow you to fetch your workspace on any other device. > To start syncing your workspace, just sign in with Google in the menu. - The file synchronization will keep one file of the workspace synced with one or multiple files in **Google Drive**, **Dropbox** or **GitHub**. > Before starting to sync files, you must link an account in the **Synchronize** sub-menu. ## Open a file You can open a file from **Google Drive**, **Dropbox** or **GitHub** by opening the **Synchronize** sub-menu and clicking **Open from**. Once opened in the workspace, any modification in the file will be automatically synced. ## Save a file You can save any file of the workspace to **Google Drive**, **Dropbox** or **GitHub** by opening the **Synchronize** sub-menu and clicking **Save on**. Even if a file in the workspace is already synced, you can save it to another location. StackEdit can sync one file with multiple locations and accounts. ## Synchronize a file Once your file is linked to a synchronized location, StackEdit will periodically synchronize it by downloading/uploading any modification. A merge will be performed if necessary and conflicts will be resolved. If you just have modified your file and you want to force syncing, click the **Synchronize now** button in the navigation bar. > **Note:** The **Synchronize now** button is disabled if you have no file to synchronize. ## Manage file synchronization Since one file can be synced with multiple locations, you can list and manage synchronized locations by clicking **File synchronization** in the **Synchronize** sub-menu. This allows you to list and remove synchronized locations that are linked to your file. # Publication Publishing in StackEdit makes it simple for you to publish online your files. Once you're happy with a file, you can publish it to different hosting platforms like **Blogger**, **Dropbox**, **Gist**, **GitHub**, **Google Drive**, **WordPress** and **Zendesk**. With [Handlebars templates](http://handlebarsjs.com/), you have full control over what you export. > Before starting to publish, you must link an account in the **Publish** sub-menu. ## Publish a File You can publish your file by opening the **Publish** sub-menu and by clicking **Publish to**. For some locations, you can choose between the following formats: - Markdown: publish the Markdown text on a website that can interpret it (**GitHub** for instance), - HTML: publish the file converted to HTML via a Handlebars template (on a blog for example). ## Update a publication After publishing, StackEdit keeps your file linked to that publication which makes it easy for you to re-publish it. Once you have modified your file and you want to update your publication, click on the **Publish now** button in the navigation bar. > **Note:** The **Publish now** button is disabled if your file has not been published yet. ## Manage file publication Since one file can be published to multiple locations, you can list and manage publish locations by clicking **File publication** in the **Publish** sub-menu. This allows you to list and remove publication locations that are linked to your file. # Markdown extensions StackEdit extends the standard Markdown syntax by adding extra **Markdown extensions**, providing you with some nice features. > **ProTip:** You can disable any **Markdown extension** in the **File properties** dialog. ## SmartyPants SmartyPants converts ASCII punctuation characters into "smart" typographic punctuation HTML entities. For example: | |ASCII |HTML | |----------------|-------------------------------|-----------------------------| |Single backticks|`'Isn't this fun?'` |'Isn't this fun?' | |Quotes |`"Isn't this fun?"` |"Isn't this fun?" | |Dashes |`-- is en-dash, --- is em-dash`|-- is en-dash, --- is em-dash| ## KaTeX You can render LaTeX mathematical expressions using [KaTeX](https://khan.github.io/KaTeX/): The *Gamma function* satisfying $\Gamma(n) = (n-1)!\quad\forall n\in\mathbb N$ is via the Euler integral $$ \Gamma(z) = \int_0^\infty t^{z-1}e^{-t}dt\,. $$ > You can find more information about **LaTeX** mathematical expressions [here](http://meta.math.stackexchange.com/questions/5020/mathjax-basic-tutorial-and-quick-reference). ## UML diagrams You can render UML diagrams using [Mermaid](https://mermaidjs.github.io/). For example, this will produce a sequence diagram: ```mermaid sequenceDiagram Alice ->> Bob: Hello Bob, how are you? Bob-->>John: How about you John? Bob--x Alice: I am good thanks! Bob-x John: I am good thanks! Note right of John: Bob thinks a long
long time, so long
that the text does
not fit on a row. Bob-->Alice: Checking with John... Alice->John: Yes... John, how are you? ``` And this will produce a flow chart: ```mermaid graph LR A[Square Rect] -- Link text --> B((Circle)) A --> C(Round Rect) B --> D{Rhombus} C --> D ``` ================================================ FILE: src/extensions/abcExtension.js ================================================ import renderAbc from 'abcjs/src/api/abc_tunebook_svg'; import extensionSvc from '../services/extensionSvc'; const render = (elt) => { const content = elt.textContent; // Create a div element const divElt = document.createElement('div'); divElt.className = 'abc-notation-block'; // Replace the pre element with the div elt.parentNode.parentNode.replaceChild(divElt, elt.parentNode); renderAbc(divElt, content, {}); }; extensionSvc.onGetOptions((options, properties) => { options.abc = properties.extensions.abc.enabled; }); extensionSvc.onSectionPreview((elt) => { elt.querySelectorAll('.prism.language-abc') .cl_each(notationElt => render(notationElt)); }); ================================================ FILE: src/extensions/emojiExtension.js ================================================ import markdownItEmoji from 'markdown-it-emoji'; import extensionSvc from '../services/extensionSvc'; extensionSvc.onGetOptions((options, properties) => { options.emoji = properties.extensions.emoji.enabled; options.emojiShortcuts = properties.extensions.emoji.shortcuts; }); extensionSvc.onInitConverter(1, (markdown, options) => { if (options.emoji) { markdown.use(markdownItEmoji, options.emojiShortcuts ? {} : { shortcuts: {} }); } }); ================================================ FILE: src/extensions/index.js ================================================ import './emojiExtension'; import './abcExtension'; import './katexExtension'; import './markdownExtension'; import './mermaidExtension'; ================================================ FILE: src/extensions/katexExtension.js ================================================ import katex from 'katex'; import markdownItMath from './libs/markdownItMath'; import extensionSvc from '../services/extensionSvc'; extensionSvc.onGetOptions((options, properties) => { options.math = properties.extensions.katex.enabled; }); extensionSvc.onInitConverter(2, (markdown, options) => { if (options.math) { markdown.use(markdownItMath); markdown.renderer.rules.inline_math = (tokens, idx) => `${markdown.utils.escapeHtml(tokens[idx].content)}`; markdown.renderer.rules.display_math = (tokens, idx) => `${markdown.utils.escapeHtml(tokens[idx].content)}`; } }); extensionSvc.onSectionPreview((elt) => { const highlighter = displayMode => (katexElt) => { if (!katexElt.highlighted) { try { katex.render(katexElt.textContent, katexElt, { displayMode }); } catch (e) { katexElt.textContent = `${e.message}`; } } katexElt.highlighted = true; }; elt.querySelectorAll('.katex--inline').cl_each(highlighter(false)); elt.querySelectorAll('.katex--display').cl_each(highlighter(true)); }); ================================================ FILE: src/extensions/libs/markdownItAnchor.js ================================================ export default (md) => { md.core.ruler.before('replacements', 'anchors', (state) => { const anchorHash = {}; let headingOpenToken; let headingContent; state.tokens.forEach((token) => { if (token.type === 'heading_open') { headingContent = ''; headingOpenToken = token; } else if (token.type === 'heading_close') { headingOpenToken.headingContent = headingContent; // According to http://pandoc.org/README.html#extension-auto_identifiers let slug = headingContent .replace(/\s/g, '-') // Replace all spaces and newlines with hyphens .replace(/[\0-,/:-@[-^`{-~]/g, '') // Remove all punctuation, except underscores, hyphens, and periods .toLowerCase(); // Convert all alphabetic characters to lowercase // Remove everything up to the first letter let i; for (i = 0; i < slug.length; i += 1) { const charCode = slug.charCodeAt(i); if ((charCode >= 0x61 && charCode <= 0x7A) || charCode > 0x7E) { break; } } // If nothing left after this, use `section` slug = slug.slice(i) || 'section'; let anchor = slug; let index = 1; while (Object.prototype.hasOwnProperty.call(anchorHash, anchor)) { anchor = `${slug}-${index}`; index += 1; } anchorHash[anchor] = true; headingOpenToken.headingAnchor = anchor; headingOpenToken.attrs = [ ['id', anchor], ]; headingOpenToken = undefined; } else if (headingOpenToken) { headingContent += token.children.reduce((result, child) => { if (child.type !== 'footnote_ref') { return result + child.content; } return result; }, ''); } }); }); }; ================================================ FILE: src/extensions/libs/markdownItMath.js ================================================ function texMath(state, silent) { let startMathPos = state.pos; if (state.src.charCodeAt(startMathPos) !== 0x24 /* $ */) { return false; } // Parse tex math according to http://pandoc.org/README.html#math let endMarker = '$'; startMathPos += 1; const afterStartMarker = state.src.charCodeAt(startMathPos); if (afterStartMarker === 0x24 /* $ */) { endMarker = '$$'; startMathPos += 1; if (state.src.charCodeAt(startMathPos) === 0x24 /* $ */) { // 3 markers are too much return false; } } else if ( // Skip if opening $ is succeeded by a space character afterStartMarker === 0x20 /* space */ || afterStartMarker === 0x09 /* \t */ || afterStartMarker === 0x0a /* \n */ ) { return false; } const endMarkerPos = state.src.indexOf(endMarker, startMathPos); if (endMarkerPos === -1) { return false; } if (state.src.charCodeAt(endMarkerPos - 1) === 0x5C /* \ */) { return false; } const nextPos = endMarkerPos + endMarker.length; if (endMarker.length === 1) { // Skip if $ is preceded by a space character const beforeEndMarker = state.src.charCodeAt(endMarkerPos - 1); if (beforeEndMarker === 0x20 /* space */ || beforeEndMarker === 0x09 /* \t */ || beforeEndMarker === 0x0a /* \n */) { return false; } // Skip if closing $ is succeeded by a digit (eg $5 $10 ...) const suffix = state.src.charCodeAt(nextPos); if (suffix >= 0x30 && suffix < 0x3A) { return false; } } if (!silent) { const token = state.push(endMarker.length === 1 ? 'inline_math' : 'display_math', '', 0); token.content = state.src.slice(startMathPos, endMarkerPos); } state.pos = nextPos; return true; } export default (md) => { md.inline.ruler.push('texMath', texMath); }; ================================================ FILE: src/extensions/libs/markdownItTasklist.js ================================================ function attrSet(token, name, value) { const index = token.attrIndex(name); const attr = [name, value]; if (index < 0) { token.attrPush(attr); } else { token.attrs[index] = attr; } } module.exports = (md) => { md.core.ruler.after('inline', 'tasklist', ({ tokens, Token }) => { for (let i = 2; i < tokens.length; i += 1) { const token = tokens[i]; if (token.content && token.content.charCodeAt(0) === 0x5b /* [ */ && token.content.charCodeAt(2) === 0x5d /* ] */ && token.content.charCodeAt(3) === 0x20 /* space */ && token.type === 'inline' && tokens[i - 1].type === 'paragraph_open' && tokens[i - 2].type === 'list_item_open' ) { const cross = token.content[1].toLowerCase(); if (cross === ' ' || cross === 'x') { const checkbox = new Token('html_inline', '', 0); if (cross === ' ') { checkbox.content = ''; } else { checkbox.content = ''; } token.children.unshift(checkbox); token.children[1].content = token.children[1].content.slice(3); token.content = token.content.slice(3); attrSet(tokens[i - 2], 'class', 'task-list-item'); } } } }); }; ================================================ FILE: src/extensions/markdownExtension.js ================================================ import Prism from 'prismjs'; import markdownitAbbr from 'markdown-it-abbr'; import markdownitDeflist from 'markdown-it-deflist'; import markdownitFootnote from 'markdown-it-footnote'; import markdownitMark from 'markdown-it-mark'; import markdownitImgsize from 'markdown-it-imsize'; import markdownitSub from 'markdown-it-sub'; import markdownitSup from 'markdown-it-sup'; import markdownitTasklist from './libs/markdownItTasklist'; import markdownitAnchor from './libs/markdownItAnchor'; import extensionSvc from '../services/extensionSvc'; const coreBaseRules = [ 'normalize', 'block', 'inline', 'linkify', 'replacements', 'smartquotes', ]; const blockBaseRules = [ 'code', 'fence', 'blockquote', 'hr', 'list', 'reference', 'heading', 'lheading', 'html_block', 'table', 'paragraph', ]; const inlineBaseRules = [ 'text', 'newline', 'escape', 'backticks', 'strikethrough', 'emphasis', 'link', 'image', 'autolink', 'html_inline', 'entity', ]; const inlineBaseRules2 = [ 'balance_pairs', 'strikethrough', 'emphasis', 'text_collapse', ]; extensionSvc.onGetOptions((options, properties) => Object .assign(options, properties.extensions.markdown)); extensionSvc.onInitConverter(0, (markdown, options) => { markdown.set({ html: true, breaks: !!options.breaks, linkify: !!options.linkify, typographer: !!options.typographer, langPrefix: 'prism language-', }); markdown.core.ruler.enable(coreBaseRules); const blockRules = blockBaseRules.slice(); if (!options.fence) { blockRules.splice(blockRules.indexOf('fence'), 1); } if (!options.table) { blockRules.splice(blockRules.indexOf('table'), 1); } markdown.block.ruler.enable(blockRules); const inlineRules = inlineBaseRules.slice(); const inlineRules2 = inlineBaseRules2.slice(); if (!options.del) { inlineRules.splice(blockRules.indexOf('strikethrough'), 1); inlineRules2.splice(blockRules.indexOf('strikethrough'), 1); } markdown.inline.ruler.enable(inlineRules); markdown.inline.ruler2.enable(inlineRules2); if (options.abbr) { markdown.use(markdownitAbbr); } if (options.deflist) { markdown.use(markdownitDeflist); } if (options.footnote) { markdown.use(markdownitFootnote); } if (options.imgsize) { markdown.use(markdownitImgsize); } if (options.mark) { markdown.use(markdownitMark); } if (options.sub) { markdown.use(markdownitSub); } if (options.sup) { markdown.use(markdownitSup); } if (options.tasklist) { markdown.use(markdownitTasklist); } markdown.use(markdownitAnchor); // Wrap tables into a div for scrolling markdown.renderer.rules.table_open = (tokens, idx, opts) => `
${markdown.renderer.renderToken(tokens, idx, opts)}`; markdown.renderer.rules.table_close = (tokens, idx, opts) => `${markdown.renderer.renderToken(tokens, idx, opts)}
`; // Transform style into align attribute to pass the HTML sanitizer const textAlignLength = 'text-align:'.length; markdown.renderer.rules.td_open = (tokens, idx, opts) => { const token = tokens[idx]; if (token.attrs && token.attrs.length && token.attrs[0][0] === 'style') { token.attrs = [ ['align', token.attrs[0][1].slice(textAlignLength)], ]; } return markdown.renderer.renderToken(tokens, idx, opts); }; markdown.renderer.rules.th_open = markdown.renderer.rules.td_open; markdown.renderer.rules.footnote_ref = (tokens, idx) => { const n = `${Number(tokens[idx].meta.id + 1)}`; let id = `fnref${n}`; if (tokens[idx].meta.subId > 0) { id += `:${tokens[idx].meta.subId}`; } return `${n}`; }; }); extensionSvc.onSectionPreview((elt, options, isEditor) => { // Highlight with Prism elt.querySelectorAll('.prism').cl_each((prismElt) => { if (!prismElt.$highlightedWithPrism) { Prism.highlightElement(prismElt); prismElt.$highlightedWithPrism = true; } }); // Transform task spans into checkboxes elt.querySelectorAll('span.task-list-item-checkbox').cl_each((spanElt) => { const checkboxElt = document.createElement('input'); checkboxElt.type = 'checkbox'; checkboxElt.className = 'task-list-item-checkbox'; if (spanElt.classList.contains('checked')) { checkboxElt.setAttribute('checked', true); } if (!isEditor) { checkboxElt.disabled = 'disabled'; } spanElt.parentNode.replaceChild(checkboxElt, spanElt); }); }); ================================================ FILE: src/extensions/mermaidExtension.js ================================================ import 'mermaid'; import extensionSvc from '../services/extensionSvc'; import utils from '../services/utils'; const config = { logLevel: 5, startOnLoad: false, arrowMarkerAbsolute: false, theme: 'neutral', flowchart: { htmlLabels: true, curve: 'linear', }, sequence: { diagramMarginX: 50, diagramMarginY: 10, actorMargin: 50, width: 150, height: 65, boxMargin: 10, boxTextMargin: 5, noteMargin: 10, messageMargin: 35, mirrorActors: true, bottomMarginAdj: 1, useMaxWidth: true, }, gantt: { titleTopMargin: 25, barHeight: 20, barGap: 4, topPadding: 50, leftPadding: 75, gridLineStartPadding: 35, fontSize: 11, fontFamily: '"Open-Sans", "sans-serif"', numberSectionStyles: 4, axisFormat: '%Y-%m-%d', }, }; const containerElt = document.createElement('div'); containerElt.className = 'hidden-rendering-container'; document.body.appendChild(containerElt); let init = () => { window.mermaid.initialize(config); init = () => {}; }; const render = (elt) => { try { init(); const svgId = `mermaid-svg-${utils.uid()}`; window.mermaid.mermaidAPI.render(svgId, elt.textContent, () => { while (elt.firstChild) { elt.removeChild(elt.lastChild); } elt.appendChild(containerElt.querySelector(`#${svgId}`)); }, containerElt); } catch (e) { console.error(e); // eslint-disable-line no-console } }; extensionSvc.onGetOptions((options, properties) => { options.mermaid = properties.extensions.mermaid.enabled; }); extensionSvc.onSectionPreview((elt) => { elt.querySelectorAll('.prism.language-mermaid') .cl_each(diagramElt => render(diagramElt.parentNode)); }); ================================================ FILE: src/icons/Alert.vue ================================================ ================================================ FILE: src/icons/ArrowLeft.vue ================================================ ================================================ FILE: src/icons/CheckCircle.vue ================================================ ================================================ FILE: src/icons/Close.vue ================================================ ================================================ FILE: src/icons/CodeBraces.vue ================================================ ================================================ FILE: src/icons/CodeTags.vue ================================================ ================================================ FILE: src/icons/ContentCopy.vue ================================================ ================================================ FILE: src/icons/ContentSave.vue ================================================ ================================================ FILE: src/icons/Database.vue ================================================ ================================================ FILE: src/icons/Delete.vue ================================================ ================================================ FILE: src/icons/DotsHorizontal.vue ================================================ ================================================ FILE: src/icons/Download.vue ================================================ ================================================ FILE: src/icons/Eye.vue ================================================ ================================================ FILE: src/icons/FileImage.vue ================================================ ================================================ FILE: src/icons/FileMultiple.vue ================================================ ================================================ FILE: src/icons/FilePlus.vue ================================================ ================================================ FILE: src/icons/Folder.vue ================================================ ================================================ FILE: src/icons/FolderMultiple.vue ================================================ ================================================ FILE: src/icons/FolderPlus.vue ================================================ ================================================ FILE: src/icons/FormatBold.vue ================================================ ================================================ FILE: src/icons/FormatItalic.vue ================================================ ================================================ FILE: src/icons/FormatListBulleted.vue ================================================ ================================================ FILE: src/icons/FormatListChecks.vue ================================================ ================================================ FILE: src/icons/FormatListNumbers.vue ================================================ ================================================ FILE: src/icons/FormatQuoteClose.vue ================================================ ================================================ FILE: src/icons/FormatSize.vue ================================================ ================================================ FILE: src/icons/FormatStrikethrough.vue ================================================ ================================================ FILE: src/icons/HelpCircle.vue ================================================ ================================================ FILE: src/icons/History.vue ================================================ ================================================ FILE: src/icons/Information.vue ================================================ ================================================ FILE: src/icons/Key.vue ================================================ ================================================ FILE: src/icons/LinkVariant.vue ================================================ ================================================ FILE: src/icons/Login.vue ================================================ ================================================ FILE: src/icons/Logout.vue ================================================ ================================================ FILE: src/icons/Magnify.vue ================================================ ================================================ FILE: src/icons/Menu.vue ================================================ ================================================ FILE: src/icons/Message.vue ================================================ ================================================ FILE: src/icons/NavigationBar.vue ================================================ ================================================ FILE: src/icons/OpenInNew.vue ================================================ ================================================ FILE: src/icons/Pen.vue ================================================ ================================================ FILE: src/icons/Printer.vue ================================================ ================================================ FILE: src/icons/Provider.vue ================================================ ================================================ FILE: src/icons/Redo.vue ================================================ ================================================ FILE: src/icons/ScrollSync.vue ================================================ ================================================ FILE: src/icons/Seal.vue ================================================ ================================================ FILE: src/icons/Settings.vue ================================================ ================================================ FILE: src/icons/SidePreview.vue ================================================ ================================================ FILE: src/icons/SignalOff.vue ================================================ ================================================ FILE: src/icons/StatusBar.vue ================================================ ================================================ FILE: src/icons/Sync.vue ================================================ ================================================ FILE: src/icons/SyncOff.vue ================================================ ================================================ FILE: src/icons/Table.vue ================================================ ================================================ FILE: src/icons/Target.vue ================================================ ================================================ FILE: src/icons/Toc.vue ================================================ ================================================ FILE: src/icons/Undo.vue ================================================ ================================================ FILE: src/icons/Upload.vue ================================================ ================================================ FILE: src/icons/ViewList.vue ================================================ ================================================ FILE: src/icons/index.js ================================================ import Vue from 'vue'; import Provider from './Provider'; import FormatBold from './FormatBold'; import FormatItalic from './FormatItalic'; import FormatQuoteClose from './FormatQuoteClose'; import LinkVariant from './LinkVariant'; import FileImage from './FileImage'; import Table from './Table'; import FormatListNumbers from './FormatListNumbers'; import FormatListBulleted from './FormatListBulleted'; import FormatSize from './FormatSize'; import FormatStrikethrough from './FormatStrikethrough'; import StatusBar from './StatusBar'; import NavigationBar from './NavigationBar'; import SidePreview from './SidePreview'; import Eye from './Eye'; import Settings from './Settings'; import FilePlus from './FilePlus'; import FileMultiple from './FileMultiple'; import FolderPlus from './FolderPlus'; import Delete from './Delete'; import Close from './Close'; import Pen from './Pen'; import Target from './Target'; import ArrowLeft from './ArrowLeft'; import HelpCircle from './HelpCircle'; import Toc from './Toc'; import Login from './Login'; import Logout from './Logout'; import Sync from './Sync'; import SyncOff from './SyncOff'; import Upload from './Upload'; import ViewList from './ViewList'; import Download from './Download'; import CodeTags from './CodeTags'; import CodeBraces from './CodeBraces'; import OpenInNew from './OpenInNew'; import Information from './Information'; import Alert from './Alert'; import SignalOff from './SignalOff'; import Folder from './Folder'; import ScrollSync from './ScrollSync'; import Printer from './Printer'; import Undo from './Undo'; import Redo from './Redo'; import ContentSave from './ContentSave'; import Message from './Message'; import History from './History'; import Database from './Database'; import Magnify from './Magnify'; import FormatListChecks from './FormatListChecks'; import CheckCircle from './CheckCircle'; import ContentCopy from './ContentCopy'; import Key from './Key'; import DotsHorizontal from './DotsHorizontal'; import Seal from './Seal'; Vue.component('iconProvider', Provider); Vue.component('iconFormatBold', FormatBold); Vue.component('iconFormatItalic', FormatItalic); Vue.component('iconFormatQuoteClose', FormatQuoteClose); Vue.component('iconLinkVariant', LinkVariant); Vue.component('iconFileImage', FileImage); Vue.component('iconTable', Table); Vue.component('iconFormatListNumbers', FormatListNumbers); Vue.component('iconFormatListBulleted', FormatListBulleted); Vue.component('iconFormatSize', FormatSize); Vue.component('iconFormatStrikethrough', FormatStrikethrough); Vue.component('iconStatusBar', StatusBar); Vue.component('iconNavigationBar', NavigationBar); Vue.component('iconSidePreview', SidePreview); Vue.component('iconEye', Eye); Vue.component('iconSettings', Settings); Vue.component('iconFilePlus', FilePlus); Vue.component('iconFileMultiple', FileMultiple); Vue.component('iconFolderPlus', FolderPlus); Vue.component('iconDelete', Delete); Vue.component('iconClose', Close); Vue.component('iconPen', Pen); Vue.component('iconTarget', Target); Vue.component('iconArrowLeft', ArrowLeft); Vue.component('iconHelpCircle', HelpCircle); Vue.component('iconToc', Toc); Vue.component('iconLogin', Login); Vue.component('iconLogout', Logout); Vue.component('iconSync', Sync); Vue.component('iconSyncOff', SyncOff); Vue.component('iconUpload', Upload); Vue.component('iconViewList', ViewList); Vue.component('iconDownload', Download); Vue.component('iconCodeTags', CodeTags); Vue.component('iconCodeBraces', CodeBraces); Vue.component('iconOpenInNew', OpenInNew); Vue.component('iconInformation', Information); Vue.component('iconAlert', Alert); Vue.component('iconSignalOff', SignalOff); Vue.component('iconFolder', Folder); Vue.component('iconScrollSync', ScrollSync); Vue.component('iconPrinter', Printer); Vue.component('iconUndo', Undo); Vue.component('iconRedo', Redo); Vue.component('iconContentSave', ContentSave); Vue.component('iconMessage', Message); Vue.component('iconHistory', History); Vue.component('iconDatabase', Database); Vue.component('iconMagnify', Magnify); Vue.component('iconFormatListChecks', FormatListChecks); Vue.component('iconCheckCircle', CheckCircle); Vue.component('iconContentCopy', ContentCopy); Vue.component('iconKey', Key); Vue.component('iconDotsHorizontal', DotsHorizontal); Vue.component('iconSeal', Seal); ================================================ FILE: src/index.js ================================================ import Vue from 'vue'; import 'babel-polyfill'; import 'indexeddbshim/dist/indexeddbshim'; import * as OfflinePluginRuntime from 'offline-plugin/runtime'; import './extensions'; import './services/optional'; import './icons'; import App from './components/App'; import store from './store'; import localDbSvc from './services/localDbSvc'; if (!indexedDB) { throw new Error('Your browser is not supported. Please upgrade to the latest version.'); } OfflinePluginRuntime.install({ onUpdateReady: () => { // Tells to new SW to take control immediately OfflinePluginRuntime.applyUpdate(); }, onUpdated: async () => { if (!store.state.light) { await localDbSvc.sync(); localStorage.updated = true; // Reload the webpage to load into the new version window.location.reload(); } }, }); if (localStorage.updated) { store.dispatch('notification/info', 'StackEdit has just updated itself!'); setTimeout(() => localStorage.removeItem('updated'), 2000); } if (!localStorage.installPrompted) { window.addEventListener('beforeinstallprompt', async (promptEvent) => { // Prevent Chrome 67 and earlier from automatically showing the prompt promptEvent.preventDefault(); try { await store.dispatch('notification/confirm', 'Add StackEdit to your home screen?'); promptEvent.prompt(); await promptEvent.userChoice; } catch (err) { // Cancel } localStorage.installPrompted = true; }); } Vue.config.productionTip = false; /* eslint-disable no-new */ new Vue({ el: '#app', store, render: h => h(App), }); ================================================ FILE: src/libs/clunderscore.js ================================================ var arrayProperties = {} var liveCollectionProperties = {} var functionProperties = {} var objectProperties = {} var slice = Array.prototype.slice arrayProperties.cl_each = function (cb) { var i = 0 var length = this.length for (; i < length; i++) { cb(this[i], i, this) } } arrayProperties.cl_map = function (cb) { var i = 0 var length = this.length var result = Array(length) for (; i < length; i++) { result[i] = cb(this[i], i, this) } return result } arrayProperties.cl_reduce = function (cb, memo) { var i = 0 var length = this.length for (; i < length; i++) { memo = cb(memo, this[i], i, this) } return memo } arrayProperties.cl_some = function (cb) { var i = 0 var length = this.length for (; i < length; i++) { if (cb(this[i], i, this)) { return true } } } arrayProperties.cl_filter = function (cb) { var i = 0 var length = this.length var result = [] for (; i < length; i++) { cb(this[i], i, this) && result.push(this[i]) } return result } liveCollectionProperties.cl_each = function (cb) { slice.call(this).cl_each(cb) } liveCollectionProperties.cl_map = function (cb) { return slice.call(this).cl_map(cb) } liveCollectionProperties.cl_filter = function (cb) { return slice.call(this).cl_filter(cb) } liveCollectionProperties.cl_reduce = function (cb, memo) { return slice.call(this).cl_reduce(cb, memo) } functionProperties.cl_bind = function (context) { var self = this var args = slice.call(arguments, 1) context = context || null return args.length ? function () { return arguments.length ? self.apply(context, args.concat(slice.call(arguments))) : self.apply(context, args) } : function () { return arguments.length ? self.apply(context, arguments) : self.call(context) } } objectProperties.cl_each = function (cb) { var i = 0 var keys = Object.keys(this) var length = keys.length for (; i < length; i++) { cb(this[keys[i]], keys[i], this) } } objectProperties.cl_map = function (cb) { var i = 0 var keys = Object.keys(this) var length = keys.length var result = Array(length) for (; i < length; i++) { result[i] = cb(this[keys[i]], keys[i], this) } return result } objectProperties.cl_reduce = function (cb, memo) { var i = 0 var keys = Object.keys(this) var length = keys.length for (; i < length; i++) { memo = cb(memo, this[keys[i]], keys[i], this) } return memo } objectProperties.cl_some = function (cb) { var i = 0 var keys = Object.keys(this) var length = keys.length for (; i < length; i++) { if (cb(this[keys[i]], keys[i], this)) { return true } } } objectProperties.cl_extend = function (obj) { if (obj) { var i = 0 var keys = Object.keys(obj) var length = keys.length for (; i < length; i++) { this[keys[i]] = obj[keys[i]] } } return this } function build(properties) { return objectProperties.cl_reduce.call(properties, function (memo, value, key) { memo[key] = { value: value, configurable: true } return memo }, {}) } arrayProperties = build(arrayProperties) liveCollectionProperties = build(liveCollectionProperties) functionProperties = build(functionProperties) objectProperties = build(objectProperties) /* eslint-disable no-extend-native */ Object.defineProperties(Array.prototype, arrayProperties) Object.defineProperties(Int8Array.prototype, arrayProperties) Object.defineProperties(Uint8Array.prototype, arrayProperties) Object.defineProperties(Uint8ClampedArray.prototype, arrayProperties) Object.defineProperties(Int16Array.prototype, arrayProperties) Object.defineProperties(Uint16Array.prototype, arrayProperties) Object.defineProperties(Int32Array.prototype, arrayProperties) Object.defineProperties(Uint32Array.prototype, arrayProperties) Object.defineProperties(Float32Array.prototype, arrayProperties) Object.defineProperties(Float64Array.prototype, arrayProperties) Object.defineProperties(Function.prototype, functionProperties) Object.defineProperties(Object.prototype, objectProperties) if (typeof window !== 'undefined') { Object.defineProperties(HTMLCollection.prototype, liveCollectionProperties) Object.defineProperties(NodeList.prototype, liveCollectionProperties) } ================================================ FILE: src/libs/htmlSanitizer.js ================================================ const aHrefSanitizationWhitelist = /^\s*(https?|ftp|mailto|tel|file):/; const imgSrcSanitizationWhitelist = /^\s*((https?|ftp|file|blob):|data:image\/)/; const urlParsingNode = window.document.createElement('a'); function sanitizeUri(uri, isImage) { const regex = isImage ? imgSrcSanitizationWhitelist : aHrefSanitizationWhitelist; urlParsingNode.setAttribute('href', uri); const normalizedVal = urlParsingNode.href; if (normalizedVal !== '' && !normalizedVal.match(regex)) { return `unsafe:${normalizedVal}`; } return uri; } var buf; /* jshint -W083 */ // Regular Expressions for parsing tags and attributes var START_TAG_REGEXP = /^<((?:[a-zA-Z])[\w:-]*)((?:\s+[\w:-]+(?:\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|[^>\s]+))?)*)\s*(\/?)\s*(>?)/, END_TAG_REGEXP = /^<\/\s*([\w:-]+)[^>]*>/, ATTR_REGEXP = /([\w:-]+)(?:\s*=\s*(?:(?:"((?:[^"])*)")|(?:'((?:[^'])*)')|([^>\s]+)))?/g, BEGIN_TAG_REGEXP = /^/g, DOCTYPE_REGEXP = /]*?)>/i, CDATA_REGEXP = //g, SURROGATE_PAIR_REGEXP = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g, // Match everything outside of normal chars and " (quote character) NON_ALPHANUMERIC_REGEXP = /([^\#-~| |!])/g; // Good source of info about elements and attributes // http://dev.w3.org/html5/spec/Overview.html#semantics // http://simon.html5.org/html-elements // Safe Void Elements - HTML5 // http://dev.w3.org/html5/spec/Overview.html#void-elements var voidElements = makeMap("area,br,col,hr,img,wbr"); // Elements that you can, intentionally, leave open (and which close themselves) // http://dev.w3.org/html5/spec/Overview.html#optional-tags var optionalEndTagBlockElements = makeMap("colgroup,dd,dt,li,p,tbody,td,tfoot,th,thead,tr"), optionalEndTagInlineElements = makeMap("rp,rt"), optionalEndTagElements = { ...optionalEndTagInlineElements, ...optionalEndTagBlockElements, }; // Safe Block Elements - HTML5 var blockElements = { ...optionalEndTagBlockElements, ...makeMap("address,article," + "aside,blockquote,caption,center,del,dir,div,dl,figure,figcaption,footer,h1,h2,h3,h4,h5," + "h6,header,hgroup,hr,ins,map,menu,nav,ol,pre,script,section,table,ul") }; // benweet: Add iframe blockElements.iframe = true; // Inline Elements - HTML5 var inlineElements = { ...optionalEndTagInlineElements, ...makeMap("a,abbr,acronym,b," + "bdi,bdo,big,br,cite,code,del,dfn,em,font,i,img,ins,kbd,label,map,mark,q,ruby,rp,rt,s," + "samp,small,span,strike,strong,sub,sup,time,tt,u,var") }; // SVG Elements // https://wiki.whatwg.org/wiki/Sanitization_rules#svg_Elements // Note: the elements animate,animateColor,animateMotion,animateTransform,set are intentionally omitted. // They can potentially allow for arbitrary javascript to be executed. See #11290 var svgElements = makeMap("circle,defs,desc,ellipse,font-face,font-face-name,font-face-src,g,glyph," + "hkern,image,linearGradient,line,marker,metadata,missing-glyph,mpath,path,polygon,polyline," + "radialGradient,rect,stop,svg,switch,text,title,tspan,use"); // Special Elements (can contain anything) var specialElements = makeMap("script,style"); var validElements = { ...voidElements, ...blockElements, ...inlineElements, ...optionalEndTagElements, ...svgElements, }; //Attributes that have href and hence need to be sanitized var uriAttrs = makeMap("background,cite,href,longdesc,src,usemap,xlink:href"); var htmlAttrs = makeMap('abbr,align,alt,axis,bgcolor,border,cellpadding,cellspacing,class,clear,' + 'color,cols,colspan,compact,coords,dir,face,headers,height,hreflang,hspace,' + 'ismap,lang,language,nohref,nowrap,rel,rev,rows,rowspan,rules,' + 'scope,scrolling,shape,size,span,start,summary,tabindex,target,title,type,' + 'valign,value,vspace,width'); // SVG attributes (without "id" and "name" attributes) // https://wiki.whatwg.org/wiki/Sanitization_rules#svg_Attributes var svgAttrs = makeMap('accent-height,accumulate,additive,alphabetic,arabic-form,ascent,' + 'baseProfile,bbox,begin,by,calcMode,cap-height,class,color,color-rendering,content,' + 'cx,cy,d,dx,dy,descent,display,dur,end,fill,fill-rule,font-family,font-size,font-stretch,' + 'font-style,font-variant,font-weight,from,fx,fy,g1,g2,glyph-name,gradientUnits,hanging,' + 'height,horiz-adv-x,horiz-origin-x,ideographic,k,keyPoints,keySplines,keyTimes,lang,' + 'marker-end,marker-mid,marker-start,markerHeight,markerUnits,markerWidth,mathematical,' + 'max,min,offset,opacity,orient,origin,overline-position,overline-thickness,panose-1,' + 'path,pathLength,points,preserveAspectRatio,r,refX,refY,repeatCount,repeatDur,' + 'requiredExtensions,requiredFeatures,restart,rotate,rx,ry,slope,stemh,stemv,stop-color,' + 'stop-opacity,strikethrough-position,strikethrough-thickness,stroke,stroke-dasharray,' + 'stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-miterlimit,stroke-opacity,' + 'stroke-width,systemLanguage,target,text-anchor,to,transform,type,u1,u2,underline-position,' + 'underline-thickness,unicode,unicode-range,units-per-em,values,version,viewBox,visibility,' + 'width,widths,x,x-height,x1,x2,xlink:actuate,xlink:arcrole,xlink:role,xlink:show,xlink:title,' + 'xlink:type,xml:base,xml:lang,xml:space,xmlns,xmlns:xlink,y,y1,y2,zoomAndPan', true); var validAttrs = { ...uriAttrs, ...svgAttrs, ...htmlAttrs, }; // benweet: Add id and allowfullscreen (YouTube iframe) validAttrs.id = true; validAttrs.allowfullscreen = true; function makeMap(str, lowercaseKeys) { var obj = {}, items = str.split(','), i; for (i = 0; i < items.length; i++) { obj[lowercaseKeys ? items[i].toLowerCase() : items[i]] = true; } return obj; } /** * @example * htmlParser(htmlString, { * start: function(tag, attrs, unary) {}, * end: function(tag) {}, * chars: function(text) {}, * comment: function(text) {} * }); * * @param {string} html string * @param {object} handler */ function htmlParser(html, handler) { if (typeof html !== 'string') { if (html === null || typeof html === 'undefined') { html = ''; } else { html = '' + html; } } var index, chars, match, stack = [], last = html, text; stack.last = function () { return stack[stack.length - 1]; }; while (html) { text = ''; chars = true; // Make sure we're not in a script or style element if (!stack.last() || !specialElements[stack.last()]) { // Comment if (html.indexOf("", index) === index) { if (handler.comment) handler.comment(html.substring(4, index)); html = html.substring(index + 3); chars = false; } // DOCTYPE } else if (DOCTYPE_REGEXP.test(html)) { match = html.match(DOCTYPE_REGEXP); if (match) { html = html.replace(match[0], ''); chars = false; } // end tag } else if (BEGING_END_TAGE_REGEXP.test(html)) { match = html.match(END_TAG_REGEXP); if (match) { html = html.substring(match[0].length); match[0].replace(END_TAG_REGEXP, parseEndTag); chars = false; } // start tag } else if (BEGIN_TAG_REGEXP.test(html)) { match = html.match(START_TAG_REGEXP); if (match) { // We only have a valid start-tag if there is a '>'. if (match[4]) { html = html.substring(match[0].length); match[0].replace(START_TAG_REGEXP, parseStartTag); } chars = false; } else { // no ending tag found --- this piece should be encoded as an entity. text += '<'; html = html.substring(1); } } if (chars) { index = html.indexOf("<"); text += index < 0 ? html : html.substring(0, index); html = index < 0 ? "" : html.substring(index); if (handler.chars) handler.chars(decodeEntities(text)); } } else { // IE versions 9 and 10 do not understand the regex '[^]', so using a workaround with [\W\w]. html = html.replace(new RegExp("([\\W\\w]*)<\\s*\\/\\s*" + stack.last() + "[^>]*>", 'i'), function (all, text) { text = text.replace(COMMENT_REGEXP, "$1").replace(CDATA_REGEXP, "$1"); if (handler.chars) handler.chars(decodeEntities(text)); return ""; }); parseEndTag("", stack.last()); } if (html == last) { // benweet // throw $sanitizeMinErr('badparse', "The sanitizer was unable to parse the following block " + // "of html: {0}", html); stack.reverse(); return stack.cl_each(function (tag) { buf.push(''); }); } last = html; } // Clean up any remaining tags parseEndTag(); function parseStartTag(tag, tagName, rest, unary) { tagName = tagName && tagName.toLowerCase(); if (blockElements[tagName]) { while (stack.last() && inlineElements[stack.last()]) { parseEndTag("", stack.last()); } } if (optionalEndTagElements[tagName] && stack.last() == tagName) { parseEndTag("", tagName); } unary = voidElements[tagName] || !!unary; if (!unary) { stack.push(tagName); } var attrs = {}; rest.replace(ATTR_REGEXP, function (match, name, doubleQuotedValue, singleQuotedValue, unquotedValue) { var value = doubleQuotedValue || singleQuotedValue || unquotedValue || ''; attrs[name] = decodeEntities(value); }); if (handler.start) handler.start(tagName, attrs, unary); } function parseEndTag(tag, tagName) { var pos = 0, i; tagName = tagName && tagName.toLowerCase(); if (tagName) { // Find the closest opened tag of the same type for (pos = stack.length - 1; pos >= 0; pos--) { if (stack[pos] == tagName) break; } } if (pos >= 0) { // Close all the open elements, up the stack for (i = stack.length - 1; i >= pos; i--) if (handler.end) handler.end(stack[i]); // Remove the open elements from the stack stack.length = pos; } } } var hiddenPre = document.createElement("pre"); /** * decodes all entities into regular string * @param value * @returns {string} A string with decoded entities. */ function decodeEntities(value) { if (!value) { return ''; } hiddenPre.innerHTML = value.replace(//g, '>'); } /** * create an HTML/XML writer which writes to buffer * @param {Array} buf use buf.jain('') to get out sanitized html string * @returns {object} in the form of { * start: function(tag, attrs, unary) {}, * end: function(tag) {}, * chars: function(text) {}, * comment: function(text) {} * } */ function htmlSanitizeWriter(buf, uriValidator) { var ignore = false; var out = buf.push.bind(buf); return { start: function (tag, attrs, unary) { tag = tag && tag.toLowerCase(); if (!ignore && specialElements[tag]) { ignore = tag; } if (!ignore && validElements[tag] === true) { out('<'); out(tag); Object.keys(attrs).forEach(function (key) { var value = attrs[key]; var lkey = key && key.toLowerCase(); var isImage = (tag === 'img' && lkey === 'src') || (lkey === 'background'); if (validAttrs[lkey] === true && (uriAttrs[lkey] !== true || uriValidator(value, isImage))) { out(' '); out(key); out('="'); out(encodeEntities(value)); out('"'); } }); out(unary ? '/>' : '>'); } }, end: function (tag) { tag = tag && tag.toLowerCase(); if (!ignore && validElements[tag] === true) { out(''); } if (tag == ignore) { ignore = false; } }, chars: function (chars) { if (!ignore) { out(encodeEntities(chars)); } }, comment: function (comment) { if (!ignore) { out(''); } } }; } function sanitizeHtml(html) { buf = []; htmlParser(html, htmlSanitizeWriter(buf, function (uri, isImage) { return !/^unsafe/.test(sanitizeUri(uri, isImage)); })); return buf.join(''); } export default { sanitizeHtml, sanitizeUri, } ================================================ FILE: src/libs/pagedown.js ================================================ var util = {}, re = window.RegExp, SETTINGS = { lineLength: 72 }; var defaultsStrings = { bold: "Strong Ctrl/Cmd+B", boldexample: "strong text", italic: "Emphasis Ctrl/Cmd+I", italicexample: "emphasized text", strikethrough: "Strikethrough Ctrl/Cmd+I", strikethroughexample: "strikethrough text", link: "Hyperlink Ctrl/Cmd+L", linkdescription: "enter link description here", linkdialog: "

Insert Hyperlink

http://example.com/ \"optional title\"

", quote: "Blockquote
Ctrl/Cmd+Q", quoteexample: "Blockquote", code: "Code Sample
 Ctrl/Cmd+K",
  codeexample: "enter code here",

  image: "Image  Ctrl/Cmd+G",
  imagedescription: "enter image description here",
  imagedialog: "

Insert Image

http://example.com/images/diagram.jpg \"optional title\"

Need
free image hosting?

", olist: "Numbered List
    Ctrl/Cmd+O", ulist: "Bulleted List
      Ctrl/Cmd+U", litem: "List item", heading: "Heading

      /

      Ctrl/Cmd+H", headingexample: "Heading", hr: "Horizontal Rule
      Ctrl/Cmd+R", undo: "Undo - Ctrl/Cmd+Z", redo: "Redo - Ctrl/Cmd+Y", help: "Markdown Editing Help" }; // options, if given, can have the following properties: // options.helpButton = { handler: yourEventHandler } // options.strings = { italicexample: "slanted text" } // `yourEventHandler` is the click handler for the help button. // If `options.helpButton` isn't given, not help button is created. // `options.strings` can have any or all of the same properties as // `defaultStrings` above, so you can just override some string displayed // to the user on a case-by-case basis, or translate all strings to // a different language. // // For backwards compatibility reasons, the `options` argument can also // be just the `helpButton` object, and `strings.help` can also be set via // `helpButton.title`. This should be considered legacy. // // The constructed editor object has the methods: // - getConverter() returns the markdown converter object that was passed to the constructor // - run() actually starts the editor; should be called after all necessary plugins are registered. Calling this more than once is a no-op. // - refreshPreview() forces the preview to be updated. This method is only available after run() was called. function Pagedown(options) { options = options || {}; if (typeof options.handler === "function") { //backwards compatible behavior options = { helpButton: options }; } options.strings = options.strings || {}; var getString = function (identifier) { return options.strings[identifier] || defaultsStrings[identifier]; }; function identity(x) { return x; } function returnFalse() { return false; } function HookCollection() { } HookCollection.prototype = { chain: function (hookname, func) { var original = this[hookname]; if (!original) { throw new Error("unknown hook " + hookname); } if (original === identity) { this[hookname] = func; } else { this[hookname] = function () { var args = Array.prototype.slice.call(arguments, 0); args[0] = original.apply(null, args); return func.apply(null, args); }; } }, set: function (hookname, func) { if (!this[hookname]) { throw new Error("unknown hook " + hookname); } this[hookname] = func; }, addNoop: function (hookname) { this[hookname] = identity; }, addFalse: function (hookname) { this[hookname] = returnFalse; } }; var hooks = this.hooks = new HookCollection(); hooks.addNoop("onPreviewRefresh"); // called with no arguments after the preview has been refreshed hooks.addNoop("postBlockquoteCreation"); // called with the user's selection *after* the blockquote was created; should return the actual to-be-inserted text hooks.addFalse("insertImageDialog"); /* called with one parameter: a callback to be called with the URL of the image. If the application creates * its own image insertion dialog, this hook should return true, and the callback should be called with the chosen * image url (or null if the user cancelled). If this hook returns false, the default dialog will be used. */ hooks.addFalse("insertLinkDialog"); var that = this, input; this.run = function () { if (input) return; // already initialized input = options.input; var commandManager = new CommandManager(hooks, getString); var uiManager; uiManager = new UIManager(input, commandManager); that.uiManager = uiManager; }; } // before: contains all the text in the input box BEFORE the selection. // after: contains all the text in the input box AFTER the selection. function Chunks() { } // startRegex: a regular expression to find the start tag // endRegex: a regular expresssion to find the end tag Chunks.prototype.findTags = function (startRegex, endRegex) { var chunkObj = this; var regex; if (startRegex) { regex = util.extendRegExp(startRegex, "", "$"); this.before = this.before.replace(regex, function (match) { chunkObj.startTag = chunkObj.startTag + match; return ""; }); regex = util.extendRegExp(startRegex, "^", ""); this.selection = this.selection.replace(regex, function (match) { chunkObj.startTag = chunkObj.startTag + match; return ""; }); } if (endRegex) { regex = util.extendRegExp(endRegex, "", "$"); this.selection = this.selection.replace(regex, function (match) { chunkObj.endTag = match + chunkObj.endTag; return ""; }); regex = util.extendRegExp(endRegex, "^", ""); this.after = this.after.replace(regex, function (match) { chunkObj.endTag = match + chunkObj.endTag; return ""; }); } }; // If remove is false, the whitespace is transferred // to the before/after regions. // // If remove is true, the whitespace disappears. Chunks.prototype.trimWhitespace = function (remove) { var beforeReplacer, afterReplacer, that = this; if (remove) { beforeReplacer = afterReplacer = ""; } else { beforeReplacer = function (s) { that.before += s; return ""; }; afterReplacer = function (s) { that.after = s + that.after; return ""; }; } this.selection = this.selection.replace(/^(\s*)/, beforeReplacer).replace(/(\s*)$/, afterReplacer); }; Chunks.prototype.skipLines = function (nLinesBefore, nLinesAfter, findExtraNewlines) { if (nLinesBefore === undefined) { nLinesBefore = 1; } if (nLinesAfter === undefined) { nLinesAfter = 1; } nLinesBefore++; nLinesAfter++; var regexText; var replacementText; // chrome bug ... documented at: http://meta.stackoverflow.com/questions/63307/blockquote-glitch-in-editor-in-chrome-6-and-7/65985#65985 if (navigator.userAgent.match(/Chrome/)) { "X".match(/()./); } this.selection = this.selection.replace(/(^\n*)/, ""); this.startTag = this.startTag + re.$1; this.selection = this.selection.replace(/(\n*$)/, ""); this.endTag = this.endTag + re.$1; this.startTag = this.startTag.replace(/(^\n*)/, ""); this.before = this.before + re.$1; this.endTag = this.endTag.replace(/(\n*$)/, ""); this.after = this.after + re.$1; if (this.before) { regexText = replacementText = ""; while (nLinesBefore--) { regexText += "\\n?"; replacementText += "\n"; } if (findExtraNewlines) { regexText = "\\n*"; } this.before = this.before.replace(new re(regexText + "$", ""), replacementText); } if (this.after) { regexText = replacementText = ""; while (nLinesAfter--) { regexText += "\\n?"; replacementText += "\n"; } if (findExtraNewlines) { regexText = "\\n*"; } this.after = this.after.replace(new re(regexText, ""), replacementText); } }; // end of Chunks // Converts \r\n and \r to \n. util.fixEolChars = function (text) { text = text.replace(/\r\n/g, "\n"); text = text.replace(/\r/g, "\n"); return text; }; // Extends a regular expression. Returns a new RegExp // using pre + regex + post as the expression. // Used in a few functions where we have a base // expression and we want to pre- or append some // conditions to it (e.g. adding "$" to the end). // The flags are unchanged. // // regex is a RegExp, pre and post are strings. util.extendRegExp = function (regex, pre, post) { if (pre === null || pre === undefined) { pre = ""; } if (post === null || post === undefined) { post = ""; } var pattern = regex.toString(); var flags; // Replace the flags with empty space and store them. pattern = pattern.replace(/\/([gim]*)$/, function (wholeMatch, flagsPart) { flags = flagsPart; return ""; }); // Remove the slash delimiters on the regular expression. pattern = pattern.replace(/(^\/|\/$)/g, ""); pattern = pre + pattern + post; return new re(pattern, flags); }; // The input textarea state/contents. // This is used to implement undo/redo by the undo manager. function TextareaState(input) { // Aliases var stateObj = this; var inputArea = input; this.init = function () { this.setInputAreaSelectionStartEnd(); this.text = inputArea.getContent(); }; // Sets the selected text in the input box after we've performed an // operation. this.setInputAreaSelection = function () { inputArea.focus(); inputArea.setSelection(stateObj.start, stateObj.end); }; this.setInputAreaSelectionStartEnd = function () { stateObj.start = Math.min( inputArea.selectionMgr.selectionStart, inputArea.selectionMgr.selectionEnd ); stateObj.end = Math.max( inputArea.selectionMgr.selectionStart, inputArea.selectionMgr.selectionEnd ); }; // Restore this state into the input area. this.restore = function () { if (stateObj.text !== undefined && stateObj.text != inputArea.getContent()) { inputArea.setContent(stateObj.text); } this.setInputAreaSelection(); }; // Gets a collection of HTML chunks from the inptut textarea. this.getChunks = function () { var chunk = new Chunks(); chunk.before = util.fixEolChars(stateObj.text.substring(0, stateObj.start)); chunk.startTag = ""; chunk.selection = util.fixEolChars(stateObj.text.substring(stateObj.start, stateObj.end)); chunk.endTag = ""; chunk.after = util.fixEolChars(stateObj.text.substring(stateObj.end)); return chunk; }; // Sets the TextareaState properties given a chunk of markdown. this.setChunks = function (chunk) { chunk.before = chunk.before + chunk.startTag; chunk.after = chunk.endTag + chunk.after; this.start = chunk.before.length; this.end = chunk.before.length + chunk.selection.length; this.text = chunk.before + chunk.selection + chunk.after; }; this.init(); } function UIManager(input, commandManager) { var inputBox = input, buttons = {}; // buttons.undo, buttons.link, etc. The actual DOM elements. makeSpritedButtonRow(); // Perform the button's action. function doClick(buttonName) { var button = buttons[buttonName]; if (!button) { return; } inputBox.focus(); var linkOrImage = button === buttons.link || button.id === buttons.image; var state = new TextareaState(input); if (!state) { return; } var chunks = state.getChunks(); // Some commands launch a "modal" prompt dialog. Javascript // can't really make a modal dialog box and the WMD code // will continue to execute while the dialog is displayed. // This prevents the dialog pattern I'm used to and means // I can't do something like this: // // var link = CreateLinkDialog(); // makeMarkdownLink(link); // // Instead of this straightforward method of handling a // dialog I have to pass any code which would execute // after the dialog is dismissed (e.g. link creation) // in a function parameter. // // Yes this is awkward and I think it sucks, but there's // no real workaround. Only the image and link code // create dialogs and require the function pointers. var fixupInputArea = function () { inputBox.focus(); if (chunks) { state.setChunks(chunks); } state.restore(); }; var noCleanup = button(chunks, fixupInputArea); if (!noCleanup) { fixupInputArea(); if (!linkOrImage) { inputBox.adjustCursorPosition(); } } } function bindCommand(method) { if (typeof method === "string") method = commandManager[method]; return function () { method.apply(commandManager, arguments); }; } function makeSpritedButtonRow() { buttons.bold = bindCommand("doBold"); buttons.italic = bindCommand("doItalic"); buttons.strikethrough = bindCommand("doStrikethrough"); buttons.link = bindCommand(function (chunk, postProcessing) { return this.doLinkOrImage(chunk, postProcessing, false); }); buttons.quote = bindCommand("doBlockquote"); buttons.code = bindCommand("doCode"); buttons.image = bindCommand(function (chunk, postProcessing) { return this.doLinkOrImage(chunk, postProcessing, true); }); buttons.olist = bindCommand(function (chunk, postProcessing) { this.doList(chunk, postProcessing, true); }); buttons.ulist = bindCommand(function (chunk, postProcessing) { this.doList(chunk, postProcessing, false); }); buttons.clist = bindCommand(function (chunk, postProcessing) { this.doList(chunk, postProcessing, false, true); }); buttons.heading = bindCommand("doHeading"); buttons.hr = bindCommand("doHorizontalRule"); buttons.table = bindCommand("doTable"); } this.doClick = doClick; } function CommandManager(pluginHooks, getString) { this.hooks = pluginHooks; this.getString = getString; } var commandProto = CommandManager.prototype; // The markdown symbols - 4 spaces = code, > = blockquote, etc. commandProto.prefixes = "(?:\\s{4,}|\\s*>|\\s*-\\s+|\\s*\\d+\\.|=|\\+|-|_|\\*|#|\\s*\\[[^\n]]+\\]:)"; // Remove markdown symbols from the chunk selection. commandProto.unwrap = function (chunk) { var txt = new re("([^\\n])\\n(?!(\\n|" + this.prefixes + "))", "g"); chunk.selection = chunk.selection.replace(txt, "$1 $2"); }; commandProto.wrap = function (chunk, len) { this.unwrap(chunk); var regex = new re("(.{1," + len + "})( +|$\\n?)", "gm"), that = this; chunk.selection = chunk.selection.replace(regex, function (line, marked) { if (new re("^" + that.prefixes, "").test(line)) { return line; } return marked + "\n"; }); chunk.selection = chunk.selection.replace(/\s+$/, ""); }; commandProto.doBold = function (chunk, postProcessing) { return this.doBorI(chunk, postProcessing, 2, this.getString("boldexample")); }; commandProto.doItalic = function (chunk, postProcessing) { return this.doBorI(chunk, postProcessing, 1, this.getString("italicexample")); }; // chunk: The selected region that will be enclosed with */** // nStars: 1 for italics, 2 for bold // insertText: If you just click the button without highlighting text, this gets inserted commandProto.doBorI = function (chunk, postProcessing, nStars, insertText) { // Get rid of whitespace and fixup newlines. chunk.trimWhitespace(); chunk.selection = chunk.selection.replace(/\n{2,}/g, "\n"); // Look for stars before and after. Is the chunk already marked up? // note that these regex matches cannot fail var starsBefore = /(\**$)/.exec(chunk.before)[0]; var starsAfter = /(^\**)/.exec(chunk.after)[0]; var prevStars = Math.min(starsBefore.length, starsAfter.length); // Remove stars if we have to since the button acts as a toggle. if ((prevStars >= nStars) && (prevStars != 2 || nStars != 1)) { chunk.before = chunk.before.replace(re("[*]{" + nStars + "}$", ""), ""); chunk.after = chunk.after.replace(re("^[*]{" + nStars + "}", ""), ""); } else if (!chunk.selection && starsAfter) { // It's not really clear why this code is necessary. It just moves // some arbitrary stuff around. chunk.after = chunk.after.replace(/^([*_]*)/, ""); chunk.before = chunk.before.replace(/(\s?)$/, ""); var whitespace = re.$1; chunk.before = chunk.before + starsAfter + whitespace; } else { // In most cases, if you don't have any selected text and click the button // you'll get a selected, marked up region with the default text inserted. if (!chunk.selection && !starsAfter) { chunk.selection = insertText; } // Add the true markup. var markup = nStars <= 1 ? "*" : "**"; // shouldn't the test be = ? chunk.before = chunk.before + markup; chunk.after = markup + chunk.after; } return; }; commandProto.doStrikethrough = function (chunk, postProcessing) { // Get rid of whitespace and fixup newlines. chunk.trimWhitespace(); chunk.selection = chunk.selection.replace(/\n{2,}/g, "\n"); // Look for stars before and after. Is the chunk already marked up? // note that these regex matches cannot fail var starsBefore = /(~*$)/.exec(chunk.before)[0]; var starsAfter = /(^~*)/.exec(chunk.after)[0]; var prevStars = Math.min(starsBefore.length, starsAfter.length); var nStars = 2; // Remove stars if we have to since the button acts as a toggle. if ((prevStars >= nStars) && (prevStars != 2 || nStars != 1)) { chunk.before = chunk.before.replace(re("[~]{" + nStars + "}$", ""), ""); chunk.after = chunk.after.replace(re("^[~]{" + nStars + "}", ""), ""); } else if (!chunk.selection && starsAfter) { // It's not really clear why this code is necessary. It just moves // some arbitrary stuff around. chunk.after = chunk.after.replace(/^(~*)/, ""); chunk.before = chunk.before.replace(/(\s?)$/, ""); var whitespace = re.$1; chunk.before = chunk.before + starsAfter + whitespace; } else { // In most cases, if you don't have any selected text and click the button // you'll get a selected, marked up region with the default text inserted. if (!chunk.selection && !starsAfter) { chunk.selection = this.getString("strikethroughexample"); } // Add the true markup. var markup = "~~"; // shouldn't the test be = ? chunk.before = chunk.before + markup; chunk.after = markup + chunk.after; } return; }; commandProto.stripLinkDefs = function (text, defsToAdd) { text = text.replace(/^[ ]{0,3}\[(\d+)\]:[ \t]*\n?[ \t]*?[ \t]*\n?[ \t]*(?:(\n*)["(](.+?)[")][ \t]*)?(?:\n+|$)/gm, function (totalMatch, id, link, newlines, title) { defsToAdd[id] = totalMatch.replace(/\s*$/, ""); if (newlines) { // Strip the title and return that separately. defsToAdd[id] = totalMatch.replace(/["(](.+?)[")]$/, ""); return newlines + title; } return ""; }); return text; }; commandProto.addLinkDef = function (chunk, linkDef) { var refNumber = 0; // The current reference number var defsToAdd = {}; // // Start with a clean slate by removing all previous link definitions. chunk.before = this.stripLinkDefs(chunk.before, defsToAdd); chunk.selection = this.stripLinkDefs(chunk.selection, defsToAdd); chunk.after = this.stripLinkDefs(chunk.after, defsToAdd); var defs = ""; var regex = /(\[)((?:\[[^\]]*\]|[^\[\]])*)(\][ ]?(?:\n[ ]*)?\[)(\d+)(\])/g; var addDefNumber = function (def) { refNumber++; def = def.replace(/^[ ]{0,3}\[(\d+)\]:/, " [" + refNumber + "]:"); defs += "\n" + def; }; // note that // a) the recursive call to getLink cannot go infinite, because by definition // of regex, inner is always a proper substring of wholeMatch, and // b) more than one level of nesting is neither supported by the regex // nor making a lot of sense (the only use case for nesting is a linked image) var getLink = function (wholeMatch, before, inner, afterInner, id, end) { inner = inner.replace(regex, getLink); if (defsToAdd[id]) { addDefNumber(defsToAdd[id]); return before + inner + afterInner + refNumber + end; } return wholeMatch; }; chunk.before = chunk.before.replace(regex, getLink); if (linkDef) { addDefNumber(linkDef); } else { chunk.selection = chunk.selection.replace(regex, getLink); } var refOut = refNumber; chunk.after = chunk.after.replace(regex, getLink); if (chunk.after) { chunk.after = chunk.after.replace(/\n*$/, ""); } if (!chunk.after) { chunk.selection = chunk.selection.replace(/\n*$/, ""); } chunk.after += "\n\n" + defs; return refOut; }; // takes the line as entered into the add link/as image dialog and makes // sure the URL and the optinal title are "nice". function properlyEncoded(linkdef) { return linkdef.replace(/^\s*(.*?)(?:\s+"(.+)")?\s*$/, function (wholematch, link, title) { link = link.replace(/\?.*$/, function (querypart) { return querypart.replace(/\+/g, " "); // in the query string, a plus and a space are identical }); link = decodeURIComponent(link); // unencode first, to prevent double encoding link = encodeURI(link).replace(/'/g, '%27').replace(/\(/g, '%28').replace(/\)/g, '%29'); link = link.replace(/\?.*$/, function (querypart) { return querypart.replace(/\+/g, "%2b"); // since we replaced plus with spaces in the query part, all pluses that now appear where originally encoded }); if (title) { title = title.trim ? title.trim() : title.replace(/^\s*/, "").replace(/\s*$/, ""); title = title.replace(/"/g, "quot;").replace(/\(/g, "(").replace(/\)/g, ")").replace(//g, ">"); } return title ? link + ' "' + title + '"' : link; }); } commandProto.doLinkOrImage = function (chunk, postProcessing, isImage) { chunk.trimWhitespace(); //chunk.findTags(/\s*!?\[/, /\][ ]?(?:\n[ ]*)?(\[.*?\])?/); chunk.findTags(/\s*!?\[/, /\][ ]?(?:\n[ ]*)?(\(.*?\))?/); if (chunk.endTag.length > 1 && chunk.startTag.length > 0) { chunk.startTag = chunk.startTag.replace(/!?\[/, ""); chunk.endTag = ""; this.addLinkDef(chunk, null); } else { // We're moving start and end tag back into the selection, since (as we're in the else block) we're not // *removing* a link, but *adding* one, so whatever findTags() found is now back to being part of the // link text. linkEnteredCallback takes care of escaping any brackets. chunk.selection = chunk.startTag + chunk.selection + chunk.endTag; chunk.startTag = chunk.endTag = ""; if (/\n\n/.test(chunk.selection)) { this.addLinkDef(chunk, null); return; } var that = this; // The function to be executed when you enter a link and press OK or Cancel. // Marks up the link and adds the ref. var linkEnteredCallback = function (link) { if (link !== null) { // ( $1 // [^\\] anything that's not a backslash // (?:\\\\)* an even number (this includes zero) of backslashes // ) // (?= followed by // [[\]] an opening or closing bracket // ) // // In other words, a non-escaped bracket. These have to be escaped now to make sure they // don't count as the end of the link or similar. // Note that the actual bracket has to be a lookahead, because (in case of to subsequent brackets), // the bracket in one match may be the "not a backslash" character in the next match, so it // should not be consumed by the first match. // The "prepend a space and finally remove it" steps makes sure there is a "not a backslash" at the // start of the string, so this also works if the selection begins with a bracket. We cannot solve // this by anchoring with ^, because in the case that the selection starts with two brackets, this // would mean a zero-width match at the start. Since zero-width matches advance the string position, // the first bracket could then not act as the "not a backslash" for the second. chunk.selection = (" " + chunk.selection).replace(/([^\\](?:\\\\)*)(?=[[\]])/g, "$1\\").substr(1); /* var linkDef = " [999]: " + properlyEncoded(link); var num = that.addLinkDef(chunk, linkDef); */ chunk.startTag = isImage ? "![" : "["; //chunk.endTag = "][" + num + "]"; chunk.endTag = "](" + properlyEncoded(link) + ")"; if (!chunk.selection) { if (isImage) { chunk.selection = that.getString("imagedescription"); } else { chunk.selection = that.getString("linkdescription"); } } } postProcessing(); }; if (isImage) { this.hooks.insertImageDialog(linkEnteredCallback); } else { this.hooks.insertLinkDialog(linkEnteredCallback); } return true; } }; // When making a list, hitting shift-enter will put your cursor on the next line // at the current indent level. commandProto.doAutoindent = function (chunk) { var commandMgr = this, fakeSelection = false; chunk.before = chunk.before.replace(/(\n|^)[ ]{0,3}([*+-]|\d+[.])[ \t]*\n$/, "\n\n"); chunk.before = chunk.before.replace(/(\n|^)[ ]{0,3}>[ \t]*\n$/, "\n\n"); chunk.before = chunk.before.replace(/(\n|^)[ \t]+\n$/, "\n\n"); // There's no selection, end the cursor wasn't at the end of the line: // The user wants to split the current list item / code line / blockquote line // (for the latter it doesn't really matter) in two. Temporarily select the // (rest of the) line to achieve this. if (!chunk.selection && !/^[ \t]*(?:\n|$)/.test(chunk.after)) { chunk.after = chunk.after.replace(/^[^\n]*/, function (wholeMatch) { chunk.selection = wholeMatch; return ""; }); fakeSelection = true; } if (/(\n|^)[ ]{0,3}([*+-]|\d+[.])[ \t]+.*\n$/.test(chunk.before)) { if (commandMgr.doList) { commandMgr.doList(chunk); } } if (/(\n|^)[ ]{0,3}>[ \t]+.*\n$/.test(chunk.before)) { if (commandMgr.doBlockquote) { commandMgr.doBlockquote(chunk); } } if (/(\n|^)(\t|[ ]{4,}).*\n$/.test(chunk.before)) { if (commandMgr.doCode) { commandMgr.doCode(chunk); } } if (fakeSelection) { chunk.after = chunk.selection + chunk.after; chunk.selection = ""; } }; commandProto.doBlockquote = function (chunk) { chunk.selection = chunk.selection.replace(/^(\n*)([^\r]+?)(\n*)$/, function (totalMatch, newlinesBefore, text, newlinesAfter) { chunk.before += newlinesBefore; chunk.after = newlinesAfter + chunk.after; return text; }); chunk.before = chunk.before.replace(/(>[ \t]*)$/, function (totalMatch, blankLine) { chunk.selection = blankLine + chunk.selection; return ""; }); chunk.selection = chunk.selection.replace(/^(\s|>)+$/, ""); chunk.selection = chunk.selection || this.getString("quoteexample"); // The original code uses a regular expression to find out how much of the // text *directly before* the selection already was a blockquote: /* if (chunk.before) { chunk.before = chunk.before.replace(/\n?$/, "\n"); } chunk.before = chunk.before.replace(/(((\n|^)(\n[ \t]*)*>(.+\n)*.*)+(\n[ \t]*)*$)/, function (totalMatch) { chunk.startTag = totalMatch; return ""; }); */ // This comes down to: // Go backwards as many lines a possible, such that each line // a) starts with ">", or // b) is almost empty, except for whitespace, or // c) is preceeded by an unbroken chain of non-empty lines // leading up to a line that starts with ">" and at least one more character // and in addition // d) at least one line fulfills a) // // Since this is essentially a backwards-moving regex, it's susceptible to // catstrophic backtracking and can cause the browser to hang; // see e.g. http://meta.stackoverflow.com/questions/9807. // // Hence we replaced this by a simple state machine that just goes through the // lines and checks for a), b), and c). var match = "", leftOver = "", line; if (chunk.before) { var lines = chunk.before.replace(/\n$/, "").split("\n"); var inChain = false; for (var i = 0; i < lines.length; i++) { var good = false; line = lines[i]; inChain = inChain && line.length > 0; // c) any non-empty line continues the chain if (/^>/.test(line)) { // a) good = true; if (!inChain && line.length > 1) // c) any line that starts with ">" and has at least one more character starts the chain inChain = true; } else if (/^[ \t]*$/.test(line)) { // b) good = true; } else { good = inChain; // c) the line is not empty and does not start with ">", so it matches if and only if we're in the chain } if (good) { match += line + "\n"; } else { leftOver += match + line; match = "\n"; } } if (!/(^|\n)>/.test(match)) { // d) leftOver += match; match = ""; } } chunk.startTag = match; chunk.before = leftOver; // end of change if (chunk.after) { chunk.after = chunk.after.replace(/^\n?/, "\n"); } chunk.after = chunk.after.replace(/^(((\n|^)(\n[ \t]*)*>(.+\n)*.*)+(\n[ \t]*)*)/, function (totalMatch) { chunk.endTag = totalMatch; return ""; } ); var replaceBlanksInTags = function (useBracket) { var replacement = useBracket ? "> " : ""; if (chunk.startTag) { chunk.startTag = chunk.startTag.replace(/\n((>|\s)*)\n$/, function (totalMatch, markdown) { return "\n" + markdown.replace(/^[ ]{0,3}>?[ \t]*$/gm, replacement) + "\n"; }); } if (chunk.endTag) { chunk.endTag = chunk.endTag.replace(/^\n((>|\s)*)\n/, function (totalMatch, markdown) { return "\n" + markdown.replace(/^[ ]{0,3}>?[ \t]*$/gm, replacement) + "\n"; }); } }; if (/^(?![ ]{0,3}>)/m.test(chunk.selection)) { this.wrap(chunk, SETTINGS.lineLength - 2); chunk.selection = chunk.selection.replace(/^/gm, "> "); replaceBlanksInTags(true); chunk.skipLines(); } else { chunk.selection = chunk.selection.replace(/^[ ]{0,3}> ?/gm, ""); this.unwrap(chunk); replaceBlanksInTags(false); if (!/^(\n|^)[ ]{0,3}>/.test(chunk.selection) && chunk.startTag) { chunk.startTag = chunk.startTag.replace(/\n{0,2}$/, "\n\n"); } if (!/(\n|^)[ ]{0,3}>.*$/.test(chunk.selection) && chunk.endTag) { chunk.endTag = chunk.endTag.replace(/^\n{0,2}/, "\n\n"); } } chunk.selection = this.hooks.postBlockquoteCreation(chunk.selection); if (!/\n/.test(chunk.selection)) { chunk.selection = chunk.selection.replace(/^(> *)/, function (wholeMatch, blanks) { chunk.startTag += blanks; return ""; }); } }; commandProto.doCode = function (chunk) { var hasTextBefore = /\S[ ]*$/.test(chunk.before); var hasTextAfter = /^[ ]*\S/.test(chunk.after); // Use 'four space' markdown if the selection is on its own // line or is multiline. if ((!hasTextAfter && !hasTextBefore) || /\n/.test(chunk.selection)) { chunk.before = chunk.before.replace(/[ ]{4}$/, function (totalMatch) { chunk.selection = totalMatch + chunk.selection; return ""; }); var nLinesBack = 1; var nLinesForward = 1; if (/(\n|^)(\t|[ ]{4,}).*\n$/.test(chunk.before)) { nLinesBack = 0; } if (/^\n(\t|[ ]{4,})/.test(chunk.after)) { nLinesForward = 0; } chunk.skipLines(nLinesBack, nLinesForward); if (!chunk.selection) { chunk.startTag = " "; chunk.selection = this.getString("codeexample"); } else { if (/^[ ]{0,3}\S/m.test(chunk.selection)) { if (/\n/.test(chunk.selection)) chunk.selection = chunk.selection.replace(/^/gm, " "); else // if it's not multiline, do not select the four added spaces; this is more consistent with the doList behavior chunk.before += " "; } else { chunk.selection = chunk.selection.replace(/^(?:[ ]{4}|[ ]{0,3}\t)/gm, ""); } } } else { // Use backticks (`) to delimit the code block. chunk.trimWhitespace(); chunk.findTags(/`/, /`/); if (!chunk.startTag && !chunk.endTag) { chunk.startTag = chunk.endTag = "`"; if (!chunk.selection) { chunk.selection = this.getString("codeexample"); } } else if (chunk.endTag && !chunk.startTag) { chunk.before += chunk.endTag; chunk.endTag = ""; } else { chunk.startTag = chunk.endTag = ""; } } }; commandProto.doList = function (chunk, postProcessing, isNumberedList, isCheckList) { // These are identical except at the very beginning and end. // Should probably use the regex extension function to make this clearer. var previousItemsRegex = /(\n|^)(([ ]{0,3}([*+-]|\d+[.])[ \t]+.*)(\n.+|\n{2,}([*+-].*|\d+[.])[ \t]+.*|\n{2,}[ \t]+\S.*)*)\n*$/; var nextItemsRegex = /^\n*(([ ]{0,3}([*+-]|\d+[.])[ \t]+.*)(\n.+|\n{2,}([*+-].*|\d+[.])[ \t]+.*|\n{2,}[ \t]+\S.*)*)\n*/; // The default bullet is a dash but others are possible. // This has nothing to do with the particular HTML bullet, // it's just a markdown bullet. var bullet = "-"; // The number in a numbered list. var num = 1; // Get the item prefix - e.g. " 1. " for a numbered list, " - " for a bulleted list. var getItemPrefix = function (checkListContent) { var prefix; if (isNumberedList) { prefix = " " + num + ". "; num++; } else { prefix = " " + bullet + " "; if (isCheckList) { prefix += '['; prefix += checkListContent || ' '; prefix += '] '; } } return prefix; }; // Fixes the prefixes of the other list items. var getPrefixedItem = function (itemText) { // The numbering flag is unset when called by autoindent. if (isNumberedList === undefined) { isNumberedList = /^\s*\d/.test(itemText); } // Renumber/bullet the list element. itemText = itemText.replace(isCheckList ? /^[ ]{0,3}([*+-]|\d+[.])\s+\[([ xX])\]\s/gm : /^[ ]{0,3}([*+-]|\d+[.])\s/gm, function (match, p1, p2) { return getItemPrefix(p2); }); return itemText; }; chunk.findTags(/(\n|^)*[ ]{0,3}([*+-]|\d+[.])\s+/, null); if (chunk.before && !/\n$/.test(chunk.before) && !/^\n/.test(chunk.startTag)) { chunk.before += chunk.startTag; chunk.startTag = ""; } if (chunk.startTag) { var hasDigits = /\d+[.]/.test(chunk.startTag); chunk.startTag = ""; chunk.selection = chunk.selection.replace(/\n[ ]{4}/g, "\n"); this.unwrap(chunk); chunk.skipLines(); if (hasDigits) { // Have to renumber the bullet points if this is a numbered list. chunk.after = chunk.after.replace(nextItemsRegex, getPrefixedItem); } if (isNumberedList == hasDigits) { return; } } var nLinesUp = 1; chunk.before = chunk.before.replace(previousItemsRegex, function (itemText) { if (/^\s*([*+-])/.test(itemText)) { bullet = re.$1; } nLinesUp = /[^\n]\n\n[^\n]/.test(itemText) ? 1 : 0; return getPrefixedItem(itemText); }); if (!chunk.selection) { chunk.selection = this.getString("litem"); } var prefix = getItemPrefix(); var nLinesDown = 1; chunk.after = chunk.after.replace(nextItemsRegex, function (itemText) { nLinesDown = /[^\n]\n\n[^\n]/.test(itemText) ? 1 : 0; return getPrefixedItem(itemText); }); chunk.trimWhitespace(true); chunk.skipLines(nLinesUp, nLinesDown, true); chunk.startTag = prefix; var spaces = prefix.replace(/./g, " "); this.wrap(chunk, SETTINGS.lineLength - spaces.length); chunk.selection = chunk.selection.replace(/\n/g, "\n" + spaces); }; commandProto.doTable = function (chunk) { // Credit: https://github.com/fcrespo82/atom-markdown-table-formatter var keepFirstAndLastPipes = true, /* ( # header capture (?: (?:[^\n]*?\|[^\n]*) # line w/ at least one pipe \ * # maybe trailing whitespace )? # maybe header (?:\n|^) # newline ) ( # format capture (?: \|\ *:?-+:?\ * # format starting w/pipe |\|?(?:\ *:?-+:?\ *\|)+ # or separated by pipe ) (?:\ *:?-+:?\ *)? # maybe w/o trailing pipe \ * # maybe trailing whitespace \n # newline ) ( # body capture (?: (?:[^\n]*?\|[^\n]*) # line w/ at least one pipe \ * # maybe trailing whitespace (?:\n|$) # newline )+ # at least one ) */ regex = /((?:(?:[^\n]*?\|[^\n]*) *)?(?:\r?\n|^))((?:\| *:?-+:? *|\|?(?: *:?-+:? *\|)+)(?: *:?-+:? *)? *\r?\n)((?:(?:[^\n]*?\|[^\n]*) *(?:\r?\n|$))+)/; function padding(len, str) { var result = ''; str = str || ' '; len = Math.floor(len); for (var i = 0; i < len; i++) { result += str; } return result; } function stripTailPipes(str) { return str.trim().replace(/(^\||\|$)/g, ""); } function splitCells(str) { return str.split('|'); } function addTailPipes(str) { if (keepFirstAndLastPipes) { return "|" + str + "|"; } else { return str; } } function joinCells(arr) { return arr.join('|'); } function formatTable(text, appendNewline) { var i, j, len1, ref1, ref2, ref3, k, len2, results, formatline, headerline, just, formatrow, data, line, lines, justify, cell, cells, first, last, ends, columns, content, widths, formatted, front, back; formatline = text[2].trim(); headerline = text[1].trim(); ref1 = headerline.length === 0 ? [0, text[3]] : [1, text[1] + text[3]], formatrow = ref1[0], data = ref1[1]; lines = data.trim().split('\n'); justify = []; ref2 = splitCells(stripTailPipes(formatline)); for (j = 0, len1 = ref2.length; j < len1; j++) { cell = ref2[j]; ref3 = cell.trim(), first = ref3[0], last = ref3[ref3.length - 1]; switch ((ends = (first ? first : ':') + (last ? last : ''))) { case '::': case '-:': case ':-': justify.push(ends); break; default: justify.push('--'); } } columns = justify.length; content = []; for (j = 0, len1 = lines.length; j < len1; j++) { line = lines[j]; cells = splitCells(stripTailPipes(line)); cells[columns - 1] = joinCells(cells.slice(columns - 1)); results = []; for (k = 0, len2 = cells.length; k < len2; k++) { cell = cells[k]; results.push(padding(' ') + ((ref2 = cell ? typeof cell.trim === "function" ? cell.trim() : void 0 : void 0) ? ref2 : '') + padding(' ')); } content.push(results); } widths = []; for (i = j = 0, ref2 = columns - 1; 0 <= ref2 ? j <= ref2 : j >= ref2; i = 0 <= ref2 ? ++j : --j) { results = []; for (k = 0, len1 = content.length; k < len1; k++) { cells = content[k]; results.push(cells[i].length); } widths.push(Math.max.apply(Math, [2].concat(results))); } just = function (string, col) { var back, front, length; length = widths[col] - string.length; switch (justify[col]) { case '::': front = padding[0], back = padding[1]; return padding(length / 2) + string + padding((length + 1) / 2); case '-:': return padding(length) + string; default: return string + padding(length); } }; formatted = []; for (j = 0, len1 = content.length; j < len1; j++) { cells = content[j]; results = []; for (i = k = 0, ref2 = columns - 1; 0 <= ref2 ? k <= ref2 : k >= ref2; i = 0 <= ref2 ? ++k : --k) { results.push(just(cells[i], i)); } formatted.push(addTailPipes(joinCells(results))); } formatline = addTailPipes(joinCells((function () { var j, ref2, ref3, results; results = []; for (i = j = 0, ref2 = columns - 1; 0 <= ref2 ? j <= ref2 : j >= ref2; i = 0 <= ref2 ? ++j : --j) { ref3 = justify[i], front = ref3[0], back = ref3[1]; results.push(front + padding(widths[i] - 2, '-') + back); } return results; })())); formatted.splice(formatrow, 0, formatline); var result = (headerline.length === 0 && text[1] !== '' ? '\n' : '') + formatted.join('\n'); if (appendNewline !== false) { result += '\n' } return result; } if (chunk.before.slice(-1) !== '\n') { chunk.before += '\n'; } var match = chunk.selection.match(regex); if (match) { chunk.selection = formatTable(match, chunk.selection.slice(-1) === '\n'); } else { var table = chunk.selection + '|\n-|-\n|'; match = table.match(regex); if (!match || match[0].slice(0, table.length) !== table) { return; } table = formatTable(match); var selectionOffset = keepFirstAndLastPipes ? 1 : 0; var pipePos = table.indexOf('|', selectionOffset); chunk.before += table.slice(0, selectionOffset); chunk.selection = table.slice(selectionOffset, pipePos); chunk.after = table.slice(pipePos) + chunk.after; } }; commandProto.doHeading = function (chunk) { // Remove leading/trailing whitespace and reduce internal spaces to single spaces. chunk.selection = chunk.selection.replace(/\s+/g, " "); chunk.selection = chunk.selection.replace(/(^\s+|\s+$)/g, ""); // If we clicked the button with no selected text, we just // make a level 2 hash header around some default text. if (!chunk.selection) { chunk.startTag = "## "; chunk.selection = this.getString("headingexample"); return; } var headerLevel = 0; // The existing header level of the selected text. // Remove any existing hash heading markdown and save the header level. chunk.findTags(/#+[ ]*/, /[ ]*#+/); if (/#+/.test(chunk.startTag)) { headerLevel = re.lastMatch.length; } chunk.startTag = chunk.endTag = ""; // Try to get the current header level by looking for - and = in the line // below the selection. chunk.findTags(null, /\s?(-+|=+)/); if (/=+/.test(chunk.endTag)) { headerLevel = 1; } if (/-+/.test(chunk.endTag)) { headerLevel = 2; } // Skip to the next line so we can create the header markdown. chunk.startTag = chunk.endTag = ""; chunk.skipLines(1, 1); // We make a level 2 header if there is no current header. // If there is a header level, we substract one from the header level. // If it's already a level 1 header, it's removed. var headerLevelToCreate = headerLevel === 0 ? 2 : headerLevel - 1; if (headerLevelToCreate > 0) { chunk.startTag = ''; while (headerLevelToCreate--) { chunk.startTag += '#'; } chunk.startTag += ' '; } }; commandProto.doHorizontalRule = function (chunk) { chunk.startTag = "----------\n"; chunk.selection = ""; chunk.skipLines(2, 1, true); }; export default function (options) { return new Pagedown(options); }; ================================================ FILE: src/services/animationSvc.js ================================================ import bezierEasing from 'bezier-easing'; const easings = { materialIn: bezierEasing(0.75, 0, 0.8, 0.25), materialOut: bezierEasing(0.25, 0.8, 0.25, 1), inOut: bezierEasing(0.25, 0.1, 0.67, 1), }; const vendors = ['moz', 'webkit']; for (let x = 0; x < vendors.length && !window.requestAnimationFrame; x += 1) { window.requestAnimationFrame = window[`${vendors[x]}RequestAnimationFrame`]; window.cancelAnimationFrame = window[`${vendors[x]}CancelAnimationFrame`] || window[`${vendors[x]}CancelRequestAnimationFrame`]; } const transformStyles = [ 'WebkitTransform', 'MozTransform', 'msTransform', 'OTransform', 'transform', ]; const transitionEndEvents = { WebkitTransition: 'webkitTransitionEnd', MozTransition: 'transitionend', msTransition: 'MSTransitionEnd', OTransition: 'oTransitionEnd', transition: 'transitionend', }; function getStyle(styles) { const elt = document.createElement('div'); return styles.reduce((result, style) => { if (elt.style[style] === undefined) { return undefined; } return style; }, undefined); } const transformStyle = getStyle(transformStyles); const transitionStyle = getStyle(Object.keys(transitionEndEvents)); const transitionEndEvent = transitionEndEvents[transitionStyle]; function identity(x) { return x; } function ElementAttribute(name) { this.name = name; this.setStart = (animation) => { const value = animation.elt[name]; animation.$start[name] = value; return value !== undefined && animation.$end[name] !== undefined; }; this.applyCurrent = (animation) => { animation.elt[name] = animation.$current[name]; }; } function StyleAttribute(name, unit, defaultValue, wrap = identity) { this.name = name; this.setStart = (animation) => { let value = parseFloat(animation.elt.style[name]); if (Number.isNaN(value)) { value = animation.$current[name] || defaultValue; } animation.$start[name] = value; return animation.$end[name] !== undefined; }; this.applyCurrent = (animation) => { animation.elt.style[name] = wrap(animation.$current[name]) + unit; }; } function TransformAttribute(name, unit, defaultValue, wrap = identity) { this.name = name; this.setStart = (animation) => { let value = animation.$current[name]; if (value === undefined) { value = defaultValue; } animation.$start[name] = value; if (animation.$end[name] === undefined) { animation.$end[name] = value; } return value !== undefined; }; this.applyCurrent = (animation) => { const value = animation.$current[name]; return value !== defaultValue && `${name}(${wrap(value)}${unit})`; }; } const attributes = [ new ElementAttribute('scrollTop'), new ElementAttribute('scrollLeft'), new StyleAttribute('opacity', '', 1), new StyleAttribute('zIndex', '', 0), new TransformAttribute('translateX', 'px', 0, Math.round), new TransformAttribute('translateY', 'px', 0, Math.round), new TransformAttribute('scale', '', 1), new TransformAttribute('rotate', 'deg', 0), ].concat([ 'width', 'height', 'top', 'right', 'bottom', 'left', ].map(name => new StyleAttribute(name, 'px', 0, Math.round))); class Animation { constructor(elt) { this.elt = elt; this.$current = {}; this.$pending = {}; } start(param1, param2, param3) { let endCb = param1; let stepCb = param2; let useTransition = false; if (typeof param1 === 'boolean') { useTransition = param1; endCb = param2; stepCb = param3; } this.stop(); this.$start = {}; this.$end = this.$pending; this.$pending = {}; this.$attributes = attributes.filter(attribute => attribute.setStart(this)); this.$end.duration = this.$end.duration || 0; this.$end.delay = this.$end.delay || 0; this.$end.easing = easings[this.$end.easing] || easings.materialOut; this.$end.endCb = typeof endCb === 'function' && endCb; this.$end.stepCb = typeof stepCb === 'function' && stepCb; this.$startTime = Date.now() + this.$end.delay; if (!this.$end.duration) { this.loop(false); } else if (useTransition) { this.loop(true); } else { this.$requestId = window.requestAnimationFrame(() => this.loop(false)); } return this.elt; } stop() { window.cancelAnimationFrame(this.$requestId); } loop(useTransition) { const onTransitionEnd = (evt) => { if (evt.target === this.elt) { this.elt.removeEventListener(transitionEndEvent, onTransitionEnd); const { endCb } = this.$end; this.$end.endCb = undefined; if (endCb) { endCb(); } } }; let progress = (Date.now() - this.$startTime) / this.$end.duration; let transition = ''; if (useTransition) { progress = 1; const transitions = [ 'all', `${this.$end.duration}ms`, this.$end.easing.toCSS(), ]; if (this.$end.delay) { transitions.push(`${this.$end.duration}ms`); } transition = transitions.join(' '); if (this.$end.endCb) { this.elt.addEventListener(transitionEndEvent, onTransitionEnd); } } else if (progress < 1) { this.$requestId = window.requestAnimationFrame(() => this.loop(false)); if (progress < 0) { return; } } else if (this.$end.endCb) { this.$requestId = window.requestAnimationFrame(this.$end.endCb); } const coeff = this.$end.easing.get(progress); const transforms = this.$attributes.reduce((result, attribute) => { if (progress < 1) { const diff = this.$end[attribute.name] - this.$start[attribute.name]; this.$current[attribute.name] = this.$start[attribute.name] + (diff * coeff); } else { this.$current[attribute.name] = this.$end[attribute.name]; } const transform = attribute.applyCurrent(this); if (transform) { result.push(transform); } return result; }, []); if (transforms.length) { transforms.push('translateZ(0)'); // activate GPU } const transform = transforms.join(' '); this.elt.style[transformStyle] = transform; this.elt.style[transitionStyle] = transition; if (this.$end.stepCb) { this.$end.stepCb(); } } } attributes.map(attribute => attribute.name).concat('duration', 'easing', 'delay') .forEach((name) => { Animation.prototype[name] = function setter(val) { this.$pending[name] = val; return this; }; }); function animate(elt) { if (!elt.$animation) { elt.$animation = new Animation(elt); } return elt.$animation; } export default { animate, }; ================================================ FILE: src/services/backupSvc.js ================================================ import workspaceSvc from './workspaceSvc'; import utils from './utils'; export default { async importBackup(jsonValue) { const fileNameMap = {}; const folderNameMap = {}; const parentIdMap = {}; const textMap = {}; const propertiesMap = {}; const discussionsMap = {}; const commentsMap = {}; const folderIdMap = { trash: 'trash', }; // Parse JSON value const parsedValue = JSON.parse(jsonValue); Object.entries(parsedValue).forEach(([id, value]) => { if (value) { const v4Match = id.match(/^file\.([^.]+)\.([^.]+)$/); if (v4Match) { // StackEdit v4 format const [, v4Id, type] = v4Match; if (type === 'title') { fileNameMap[v4Id] = value; } else if (type === 'content') { textMap[v4Id] = value; } } else if (value.type === 'folder') { // StackEdit v5 folder folderIdMap[id] = utils.uid(); folderNameMap[id] = value.name; parentIdMap[id] = `${value.parentId || ''}`; } else if (value.type === 'file') { // StackEdit v5 file fileNameMap[id] = value.name; parentIdMap[id] = `${value.parentId || ''}`; } else if (value.type === 'content') { // StackEdit v5 content const [fileId] = id.split('/'); if (fileId) { textMap[fileId] = value.text; propertiesMap[fileId] = value.properties; discussionsMap[fileId] = value.discussions; commentsMap[fileId] = value.comments; } } } }); await utils.awaitSequence( Object.keys(folderNameMap), async externalId => workspaceSvc.setOrPatchItem({ id: folderIdMap[externalId], type: 'folder', name: folderNameMap[externalId], parentId: folderIdMap[parentIdMap[externalId]], }), ); await utils.awaitSequence( Object.keys(fileNameMap), async externalId => workspaceSvc.createFile({ name: fileNameMap[externalId], parentId: folderIdMap[parentIdMap[externalId]], text: textMap[externalId], properties: propertiesMap[externalId], discussions: discussionsMap[externalId], comments: commentsMap[externalId], }, true), ); }, }; ================================================ FILE: src/services/badgeSvc.js ================================================ import store from '../store'; let lastEarnedFeatureIds = null; let debounceTimeoutId; const showInfo = () => { const earnedBadges = store.getters['data/allBadges'] .filter(badge => badge.isEarned && !lastEarnedFeatureIds.has(badge.featureId)); if (earnedBadges.length) { store.dispatch('notification/badge', earnedBadges.length > 1 ? `You've earned ${earnedBadges.length} badges: ${earnedBadges.map(badge => `"${badge.name}"`).join(', ')}.` : `You've earned 1 badge: "${earnedBadges[0].name}".`); } lastEarnedFeatureIds = null; }; export default { addBadge(featureId) { if (!store.getters['data/badgeCreations'][featureId]) { if (!lastEarnedFeatureIds) { const earnedFeatureIds = store.getters['data/allBadges'] .filter(badge => badge.isEarned) .map(badge => badge.featureId); lastEarnedFeatureIds = new Set(earnedFeatureIds); } store.dispatch('data/patchBadgeCreations', { [featureId]: { created: Date.now(), }, }); clearTimeout(debounceTimeoutId); debounceTimeoutId = setTimeout(() => showInfo(), 5000); } }, }; ================================================ FILE: src/services/diffUtils.js ================================================ import DiffMatchPatch from 'diff-match-patch'; import utils from './utils'; const diffMatchPatch = new DiffMatchPatch(); diffMatchPatch.Match_Distance = 10000; function makePatchableText(content, markerKeys, markerIdxMap) { if (!content || !content.discussions) { return null; } const markers = []; // Sort keys to have predictable marker positions in case of same offset const discussionKeys = Object.keys(content.discussions).sort(); discussionKeys.forEach((discussionId) => { const discussion = content.discussions[discussionId]; function addMarker(offsetName) { const markerKey = discussionId + offsetName; if (discussion[offsetName] !== undefined) { let idx = markerIdxMap[markerKey]; if (idx === undefined) { idx = markerKeys.length; markerIdxMap[markerKey] = idx; markerKeys.push({ id: discussionId, offsetName, }); } markers.push({ idx, offset: discussion[offsetName], }); } } addMarker('start'); addMarker('end'); }); let lastOffset = 0; let result = ''; markers .sort((marker1, marker2) => marker1.offset - marker2.offset) .forEach((marker) => { result += content.text.slice(lastOffset, marker.offset) + String.fromCharCode(0xe000 + marker.idx); // Use a character from the private use area lastOffset = marker.offset; }); return result + content.text.slice(lastOffset); } function stripDiscussionOffsets(objectMap) { if (objectMap == null) { return objectMap; } const result = {}; Object.keys(objectMap).forEach((id) => { result[id] = { text: objectMap[id].text, }; }); return result; } function restoreDiscussionOffsets(content, markerKeys) { if (markerKeys.length) { // Go through markers let count = 0; content.text = content.text.replace( new RegExp(`[\ue000-${String.fromCharCode((0xe000 + markerKeys.length) - 1)}]`, 'g'), (match, offset) => { const idx = match.charCodeAt(0) - 0xe000; const markerKey = markerKeys[idx]; const discussion = content.discussions[markerKey.id]; if (discussion) { discussion[markerKey.offsetName] = offset - count; } count += 1; return ''; }, ); // Sanitize offsets Object.keys(content.discussions).forEach((discussionId) => { const discussion = content.discussions[discussionId]; if (discussion.start === undefined) { discussion.start = discussion.end || 0; } if (discussion.end === undefined || discussion.end < discussion.start) { discussion.end = discussion.start; } }); } } function mergeText(serverText, clientText, lastMergedText) { const serverClientDiffs = diffMatchPatch.diff_main(serverText, clientText); diffMatchPatch.diff_cleanupSemantic(serverClientDiffs); // Fusion text is a mix of both server and client contents const fusionText = serverClientDiffs.map(diff => diff[1]).join(''); if (!lastMergedText) { return fusionText; } // Let's try to find out what text has to be removed from fusion const intersectionText = serverClientDiffs // Keep only equalities .filter(diff => diff[0] === DiffMatchPatch.DIFF_EQUAL) .map(diff => diff[1]).join(''); const lastMergedTextDiffs = diffMatchPatch.diff_main(lastMergedText, intersectionText) // Keep only equalities and deletions .filter(diff => diff[0] !== DiffMatchPatch.DIFF_INSERT); diffMatchPatch.diff_cleanupSemantic(lastMergedTextDiffs); // Make a patch with deletions only const patches = diffMatchPatch.patch_make(lastMergedText, lastMergedTextDiffs); // Apply patch to fusion text return diffMatchPatch.patch_apply(patches, fusionText)[0]; } function mergeValues(serverValue, clientValue, lastMergedValue) { if (!lastMergedValue) { return serverValue || clientValue; // Take the server value in priority } const newSerializedValue = utils.serializeObject(clientValue); const serverSerializedValue = utils.serializeObject(serverValue); if (newSerializedValue === serverSerializedValue) { return serverValue; // no conflict } const oldSerializedValue = utils.serializeObject(lastMergedValue); if (oldSerializedValue !== newSerializedValue && !serverValue) { return clientValue; // Removed on server but changed on client } if (oldSerializedValue !== serverSerializedValue && !clientValue) { return serverValue; // Removed on client but changed on server } if (oldSerializedValue !== newSerializedValue && oldSerializedValue === serverSerializedValue) { return clientValue; // Take the client value } return serverValue; // Take the server value } function mergeObjects(serverObject, clientObject, lastMergedObject = {}) { const mergedObject = {}; Object.keys({ ...clientObject, ...serverObject, }).forEach((key) => { const mergedValue = mergeValues(serverObject[key], clientObject[key], lastMergedObject[key]); if (mergedValue != null) { mergedObject[key] = mergedValue; } }); return utils.deepCopy(mergedObject); } function mergeContent(serverContent, clientContent, lastMergedContent = {}) { const markerKeys = []; const markerIdxMap = Object.create(null); const lastMergedText = makePatchableText(lastMergedContent, markerKeys, markerIdxMap); const serverText = makePatchableText(serverContent, markerKeys, markerIdxMap); const clientText = makePatchableText(clientContent, markerKeys, markerIdxMap); const isServerTextChanges = lastMergedText !== serverText; const isClientTextChanges = lastMergedText !== clientText; const isTextSynchronized = serverText === clientText; let text = clientText; if (!isTextSynchronized && isServerTextChanges) { text = serverText; if (isClientTextChanges) { text = mergeText(serverText, clientText, lastMergedText); } } const result = { text, properties: mergeValues( serverContent.properties, clientContent.properties, lastMergedContent.properties, ), discussions: mergeObjects( stripDiscussionOffsets(serverContent.discussions), stripDiscussionOffsets(clientContent.discussions), stripDiscussionOffsets(lastMergedContent.discussions), ), comments: mergeObjects( serverContent.comments, clientContent.comments, lastMergedContent.comments, ), }; restoreDiscussionOffsets(result, markerKeys); return result; } export default { makePatchableText, restoreDiscussionOffsets, mergeObjects, mergeContent, }; ================================================ FILE: src/services/editor/cledit/cleditCore.js ================================================ import DiffMatchPatch from 'diff-match-patch'; import TurndownService from 'turndown/lib/turndown.browser.umd'; import htmlSanitizer from '../../../libs/htmlSanitizer'; import store from '../../../store'; function cledit(contentElt, scrollEltOpt, isMarkdown = false) { const scrollElt = scrollEltOpt || contentElt; const editor = { $contentElt: contentElt, $scrollElt: scrollElt, $keystrokes: [], $markers: {}, }; cledit.Utils.createEventHooks(editor); const { debounce } = cledit.Utils; contentElt.setAttribute('tabindex', '0'); // To have focus even when disabled editor.toggleEditable = (isEditable) => { contentElt.contentEditable = isEditable == null ? !contentElt.contentEditable : isEditable; }; editor.toggleEditable(true); function getTextContent() { // Markdown-it sanitization (Mac/DOS to Unix) let textContent = contentElt.textContent.replace(/\r[\n\u0085]?|[\u2424\u2028\u0085]/g, '\n'); if (textContent.slice(-1) !== '\n') { textContent += '\n'; } return textContent; } let lastTextContent = getTextContent(); const highlighter = new cledit.Highlighter(editor); /* eslint-disable new-cap */ const diffMatchPatch = new DiffMatchPatch(); /* eslint-enable new-cap */ const selectionMgr = new cledit.SelectionMgr(editor); function adjustCursorPosition(force) { selectionMgr.saveSelectionState(true, true, force); } function replaceContent(selectionStart, selectionEnd, replacement) { const min = Math.min(selectionStart, selectionEnd); const max = Math.max(selectionStart, selectionEnd); const range = selectionMgr.createRange(min, max); const rangeText = `${range}`; // Range can contain a br element, which is not taken into account in rangeText if (rangeText.length === max - min && rangeText === replacement) { return null; } range.deleteContents(); range.insertNode(document.createTextNode(replacement)); return range; } let ignoreUndo = false; let noContentFix = false; function setContent(value, noUndo, maxStartOffsetOpt) { const textContent = getTextContent(); const maxStartOffset = maxStartOffsetOpt != null && maxStartOffsetOpt < textContent.length ? maxStartOffsetOpt : textContent.length - 1; const startOffset = Math.min( diffMatchPatch.diff_commonPrefix(textContent, value), maxStartOffset, ); const endOffset = Math.min( diffMatchPatch.diff_commonSuffix(textContent, value), textContent.length - startOffset, value.length - startOffset, ); const replacement = value.substring(startOffset, value.length - endOffset); const range = replaceContent(startOffset, textContent.length - endOffset, replacement); if (range) { ignoreUndo = noUndo; noContentFix = true; } return { start: startOffset, end: value.length - endOffset, range, }; } const undoMgr = new cledit.UndoMgr(editor); function replace(selectionStart, selectionEnd, replacement) { undoMgr.setDefaultMode('single'); replaceContent(selectionStart, selectionEnd, replacement); const startOffset = Math.min(selectionStart, selectionEnd); const endOffset = startOffset + replacement.length; selectionMgr.setSelectionStartEnd(endOffset, endOffset); selectionMgr.updateCursorCoordinates(true); } function replaceAll(search, replacement, startOffset = 0) { undoMgr.setDefaultMode('single'); const text = getTextContent(); const subtext = getTextContent().slice(startOffset); const value = subtext.replace(search, replacement); if (value !== subtext) { const offset = editor.setContent(text.slice(0, startOffset) + value); selectionMgr.setSelectionStartEnd(offset.end, offset.end); selectionMgr.updateCursorCoordinates(true); } } function focus() { selectionMgr.restoreSelection(); contentElt.focus(); } function addMarker(marker) { editor.$markers[marker.id] = marker; } function removeMarker(marker) { delete editor.$markers[marker.id]; } const triggerSpellCheck = debounce(() => { // Hack for Chrome to trigger the spell checker const selection = window.getSelection(); if (selectionMgr.hasFocus() && !highlighter.isComposing && selectionMgr.selectionStart === selectionMgr.selectionEnd && selection.modify ) { if (selectionMgr.selectionStart) { selection.modify('move', 'backward', 'character'); selection.modify('move', 'forward', 'character'); } else { selection.modify('move', 'forward', 'character'); selection.modify('move', 'backward', 'character'); } } }, 10); let watcher; let skipSaveSelection; function checkContentChange(mutations) { watcher.noWatch(() => { const removedSections = []; const modifiedSections = []; function markModifiedSection(node) { let currentNode = node; while (currentNode && currentNode !== contentElt) { if (currentNode.section) { const array = currentNode.parentNode ? modifiedSections : removedSections; if (array.indexOf(currentNode.section) === -1) { array.push(currentNode.section); } return; } currentNode = currentNode.parentNode; } } mutations.cl_each((mutation) => { markModifiedSection(mutation.target); mutation.addedNodes.cl_each(markModifiedSection); mutation.removedNodes.cl_each(markModifiedSection); }); highlighter.fixContent(modifiedSections, removedSections, noContentFix); noContentFix = false; }); if (!skipSaveSelection) { selectionMgr.saveSelectionState(); } skipSaveSelection = false; const newTextContent = getTextContent(); const diffs = diffMatchPatch.diff_main(lastTextContent, newTextContent); editor.$markers.cl_each((marker) => { marker.adjustOffset(diffs); }); const sectionList = highlighter.parseSections(newTextContent); editor.$trigger('contentChanged', newTextContent, diffs, sectionList); if (!ignoreUndo) { undoMgr.addDiffs(lastTextContent, newTextContent, diffs); undoMgr.setDefaultMode('typing'); undoMgr.saveState(); } ignoreUndo = false; lastTextContent = newTextContent; triggerSpellCheck(); } // Detect editor changes watcher = new cledit.Watcher(editor, checkContentChange); watcher.startWatching(); function setSelection(start, end) { selectionMgr.setSelectionStartEnd(start, end == null ? start : end); selectionMgr.updateCursorCoordinates(); } function keydownHandler(handler) { return (evt) => { if ( evt.which !== 17 && // Ctrl evt.which !== 91 && // Cmd evt.which !== 18 && // Alt evt.which !== 16 // Shift ) { handler(evt); } }; } let windowKeydownListener; let windowMouseListener; let windowResizeListener; function tryDestroy() { if (document.contains(contentElt)) { return false; } watcher.stopWatching(); window.removeEventListener('keydown', windowKeydownListener); window.removeEventListener('mousedown', windowMouseListener); window.removeEventListener('mouseup', windowMouseListener); window.removeEventListener('resize', windowResizeListener); editor.$trigger('destroy'); return true; } // In case of Ctrl/Cmd+A outside the editor element windowKeydownListener = (evt) => { if (!tryDestroy()) { keydownHandler(() => { adjustCursorPosition(); })(evt); } }; window.addEventListener('keydown', windowKeydownListener); // Mouseup can happen outside the editor element windowMouseListener = () => { if (!tryDestroy()) { selectionMgr.saveSelectionState(true, false); } }; window.addEventListener('mousedown', windowMouseListener); window.addEventListener('mouseup', windowMouseListener); // Resize provokes cursor coordinate changes windowResizeListener = () => { if (!tryDestroy()) { selectionMgr.updateCursorCoordinates(); } }; window.addEventListener('resize', windowResizeListener); // Provokes selection changes and does not fire mouseup event on Chrome/OSX contentElt.addEventListener( 'contextmenu', selectionMgr.saveSelectionState.cl_bind(selectionMgr, true, false), ); contentElt.addEventListener('keydown', keydownHandler((evt) => { selectionMgr.saveSelectionState(); // Perform keystroke let contentChanging = false; const textContent = getTextContent(); let min = Math.min(selectionMgr.selectionStart, selectionMgr.selectionEnd); let max = Math.max(selectionMgr.selectionStart, selectionMgr.selectionEnd); const state = { before: textContent.slice(0, min), after: textContent.slice(max), selection: textContent.slice(min, max), isBackwardSelection: selectionMgr.selectionStart > selectionMgr.selectionEnd, }; editor.$keystrokes.cl_some((keystroke) => { if (!keystroke.handler(evt, state, editor)) { return false; } const newContent = state.before + state.selection + state.after; if (newContent !== getTextContent()) { editor.setContent(newContent, false, min); contentChanging = true; skipSaveSelection = true; highlighter.cancelComposition = true; } min = state.before.length; max = min + state.selection.length; selectionMgr.setSelectionStartEnd( state.isBackwardSelection ? max : min, state.isBackwardSelection ? min : max, !contentChanging, // Expect a restore selection on mutation event ); return true; }); if (!contentChanging) { // Optimization to avoid saving selection adjustCursorPosition(); } })); contentElt.addEventListener('compositionstart', () => { highlighter.isComposing += 1; }); contentElt.addEventListener('compositionend', () => { setTimeout(() => { if (highlighter.isComposing) { highlighter.isComposing -= 1; if (!highlighter.isComposing) { checkContentChange([]); } } }, 1); }); let turndownService; if (isMarkdown) { contentElt.addEventListener('copy', (evt) => { if (evt.clipboardData) { evt.clipboardData.setData('text/plain', selectionMgr.getSelectedText()); evt.preventDefault(); } }); contentElt.addEventListener('cut', (evt) => { if (evt.clipboardData) { evt.clipboardData.setData('text/plain', selectionMgr.getSelectedText()); evt.preventDefault(); replace(selectionMgr.selectionStart, selectionMgr.selectionEnd, ''); } else { undoMgr.setCurrentMode('single'); } adjustCursorPosition(); }); turndownService = new TurndownService(store.getters['data/computedSettings'].turndown); turndownService.escape = str => str; // Disable escaping } contentElt.addEventListener('paste', (evt) => { undoMgr.setCurrentMode('single'); evt.preventDefault(); let data; let { clipboardData } = evt; if (clipboardData) { data = clipboardData.getData('text/plain'); if (turndownService) { try { const html = clipboardData.getData('text/html'); if (html) { const sanitizedHtml = htmlSanitizer.sanitizeHtml(html) .replace(/ /g, ' '); // Replace non-breaking spaces with classic spaces if (sanitizedHtml) { data = turndownService.turndown(sanitizedHtml); } } } catch (e) { // Ignore } } } else { ({ clipboardData } = window.clipboardData); data = clipboardData && clipboardData.getData('Text'); } if (!data) { return; } replace(selectionMgr.selectionStart, selectionMgr.selectionEnd, data); adjustCursorPosition(); }); contentElt.addEventListener('focus', () => { editor.$trigger('focus'); }); contentElt.addEventListener('blur', () => { editor.$trigger('blur'); }); function addKeystroke(keystroke) { const keystrokes = Array.isArray(keystroke) ? keystroke : [keystroke]; editor.$keystrokes = editor.$keystrokes .concat(keystrokes) .sort((keystroke1, keystroke2) => keystroke1.priority - keystroke2.priority); } addKeystroke(cledit.defaultKeystrokes); editor.selectionMgr = selectionMgr; editor.undoMgr = undoMgr; editor.highlighter = highlighter; editor.watcher = watcher; editor.adjustCursorPosition = adjustCursorPosition; editor.setContent = setContent; editor.replace = replace; editor.replaceAll = replaceAll; editor.getContent = getTextContent; editor.focus = focus; editor.setSelection = setSelection; editor.addKeystroke = addKeystroke; editor.addMarker = addMarker; editor.removeMarker = removeMarker; editor.init = (opts = {}) => { const options = ({ getCursorFocusRatio() { return 0.1; }, sectionHighlighter(section) { return section.text.replace(/&/g, '&').replace(/ document.head.contains(styleElt))) { createStyleSheet(document); } const contentElt = editor.$contentElt; this.isComposing = 0; let sectionList = []; let insertBeforeSection; const useBr = cledit.Utils.isWebkit; const trailingNodeTag = 'div'; const hiddenLfInnerHtml = '
      '; const lfHtml = `${useBr ? hiddenLfInnerHtml : '\n'}`; this.fixContent = (modifiedSections, removedSections, noContentFix) => { modifiedSections.cl_each((section) => { section.forceHighlighting = true; if (!noContentFix) { if (useBr) { section.elt.getElementsByClassName('hd-lf') .cl_each(lfElt => lfElt.parentNode.removeChild(lfElt)); section.elt.getElementsByTagName('br') .cl_each(brElt => brElt.parentNode.replaceChild(document.createTextNode('\n'), brElt)); } if (section.elt.textContent.slice(-1) !== '\n') { section.elt.appendChild(document.createTextNode('\n')); } } }); }; this.addTrailingNode = () => { this.trailingNode = document.createElement(trailingNodeTag); contentElt.appendChild(this.trailingNode); }; class Section { constructor(text) { this.text = text.text === undefined ? text : text.text; this.data = text.data; } setElement(elt) { this.elt = elt; elt.section = this; } } this.parseSections = (content, isInit) => { if (this.isComposing && !this.cancelComposition) { return sectionList; } this.cancelComposition = false; const newSectionList = (editor.options.sectionParser ? editor.options.sectionParser(content) : [content]) .cl_map(sectionText => new Section(sectionText)); let modifiedSections = []; let sectionsToRemove = []; insertBeforeSection = undefined; if (isInit) { // Render everything if isInit sectionsToRemove = sectionList; sectionList = newSectionList; modifiedSections = newSectionList; } else { // Find modified section starting from top let leftIndex = sectionList.length; sectionList.cl_some((section, index) => { const newSection = newSectionList[index]; if (index >= newSectionList.length || section.forceHighlighting || // Check text modification section.text !== newSection.text || // Check that section has not been detached or moved section.elt.parentNode !== contentElt || // Check also the content since nodes can be injected in sections via copy/paste section.elt.textContent !== newSection.text ) { leftIndex = index; return true; } return false; }); // Find modified section starting from bottom let rightIndex = -sectionList.length; sectionList.slice().reverse().cl_some((section, index) => { const newSection = newSectionList[newSectionList.length - index - 1]; if (index >= newSectionList.length || section.forceHighlighting || // Check modified section.text !== newSection.text || // Check that section has not been detached or moved section.elt.parentNode !== contentElt || // Check also the content since nodes can be injected in sections via copy/paste section.elt.textContent !== newSection.text ) { rightIndex = -index; return true; } return false; }); if (leftIndex - rightIndex > sectionList.length) { // Prevent overlap rightIndex = leftIndex - sectionList.length; } const leftSections = sectionList.slice(0, leftIndex); modifiedSections = newSectionList.slice(leftIndex, newSectionList.length + rightIndex); const rightSections = sectionList.slice(sectionList.length + rightIndex, sectionList.length); [insertBeforeSection] = rightSections; sectionsToRemove = sectionList.slice(leftIndex, sectionList.length + rightIndex); sectionList = leftSections.concat(modifiedSections).concat(rightSections); } const highlight = (section) => { const html = editor.options.sectionHighlighter(section).replace(/\n/g, lfHtml); const sectionElt = document.createElement('div'); sectionElt.className = 'cledit-section'; sectionElt.innerHTML = html; section.setElement(sectionElt); this.$trigger('sectionHighlighted', section); }; const newSectionEltList = document.createDocumentFragment(); modifiedSections.cl_each((section) => { section.forceHighlighting = false; highlight(section); newSectionEltList.appendChild(section.elt); }); editor.watcher.noWatch(() => { if (isInit) { contentElt.innerHTML = ''; contentElt.appendChild(newSectionEltList); this.addTrailingNode(); return; } // Remove outdated sections sectionsToRemove.cl_each((section) => { // section may be already removed if (section.elt.parentNode === contentElt) { contentElt.removeChild(section.elt); } // To detect sections that come back with built-in undo section.elt.section = undefined; }); if (insertBeforeSection !== undefined) { contentElt.insertBefore(newSectionEltList, insertBeforeSection.elt); } else { contentElt.appendChild(newSectionEltList); } // Remove unauthorized nodes (text nodes outside of sections or // duplicated sections via copy/paste) let childNode = contentElt.firstChild; while (childNode) { const nextNode = childNode.nextSibling; if (!childNode.section) { contentElt.removeChild(childNode); } childNode = nextNode; } this.addTrailingNode(); this.$trigger('highlighted'); if (editor.selectionMgr.hasFocus()) { editor.selectionMgr.restoreSelection(); editor.selectionMgr.updateCursorCoordinates(); } }); return sectionList; }; } cledit.Highlighter = Highlighter; ================================================ FILE: src/services/editor/cledit/cleditKeystroke.js ================================================ import cledit from './cleditCore'; function Keystroke(handler, priority) { this.handler = handler; this.priority = priority || 100; } cledit.Keystroke = Keystroke; let clearNewline; const charTypes = Object.create(null); // Word separators, as in Sublime Text './\\()"\'-:,.;<>~!@#$%^&*|+=[]{}`~?'.split('').cl_each((wordSeparator) => { charTypes[wordSeparator] = 'wordSeparator'; }); charTypes[' '] = 'space'; charTypes['\t'] = 'space'; charTypes['\n'] = 'newLine'; function getNextWordOffset(text, offset, isBackward) { let previousType; let result = offset; while ((isBackward && result > 0) || (!isBackward && result < text.length)) { const currentType = charTypes[isBackward ? text[result - 1] : text[result]] || 'word'; if (previousType && currentType !== previousType) { if (previousType === 'word' || currentType === 'space' || previousType === 'newLine' || currentType === 'newLine') { break; } } previousType = currentType; if (isBackward) { result -= 1; } else { result += 1; } } return result; } cledit.defaultKeystrokes = [ new Keystroke((evt, state, editor) => { if ((!evt.ctrlKey && !evt.metaKey) || evt.altKey) { return false; } const keyCode = evt.charCode || evt.keyCode; const keyCodeChar = String.fromCharCode(keyCode).toLowerCase(); let action; switch (keyCodeChar) { case 'y': action = 'redo'; break; case 'z': action = evt.shiftKey ? 'redo' : 'undo'; break; default: } if (action) { evt.preventDefault(); setTimeout(() => editor.undoMgr[action](), 10); return true; } return false; }), new Keystroke((evt, state) => { if (evt.which !== 9 /* tab */ || evt.metaKey || evt.ctrlKey) { return false; } const strSplice = (str, i, remove, add = '') => str.slice(0, i) + add + str.slice(i + (+remove || 0)); evt.preventDefault(); const isInverse = evt.shiftKey; const lf = state.before.lastIndexOf('\n') + 1; if (isInverse) { if (/\s/.test(state.before.charAt(lf))) { state.before = strSplice(state.before, lf, 1); } state.selection = state.selection.replace(/^[ \t]/gm, ''); } else if (state.selection) { state.before = strSplice(state.before, lf, 0, '\t'); state.selection = state.selection.replace(/\n(?=[\s\S])/g, '\n\t'); } else { state.before += '\t'; } return true; }), new Keystroke((evt, state, editor) => { if (evt.which !== 13 /* enter */) { clearNewline = false; return false; } evt.preventDefault(); const lf = state.before.lastIndexOf('\n') + 1; if (clearNewline) { state.before = state.before.substring(0, lf); state.selection = ''; clearNewline = false; return true; } clearNewline = false; const previousLine = state.before.slice(lf); const indent = previousLine.match(/^\s*/)[0]; if (indent.length) { clearNewline = true; } editor.undoMgr.setCurrentMode('single'); state.before += `\n${indent}`; state.selection = ''; return true; }), new Keystroke((evt, state, editor) => { if (evt.which !== 8 /* backspace */ && evt.which !== 46 /* delete */) { return false; } editor.undoMgr.setCurrentMode('delete'); if (!state.selection) { const isJump = (cledit.Utils.isMac && evt.altKey) || (!cledit.Utils.isMac && evt.ctrlKey); if (isJump) { // Custom kill word behavior const text = state.before + state.after; const offset = getNextWordOffset(text, state.before.length, evt.which === 8); if (evt.which === 8) { state.before = state.before.slice(0, offset); } else { state.after = state.after.slice(offset - text.length); } evt.preventDefault(); return true; } else if (evt.which === 8 && state.before.slice(-1) === '\n') { // Special treatment for end of lines state.before = state.before.slice(0, -1); evt.preventDefault(); return true; } else if (evt.which === 46 && state.after.slice(0, 1) === '\n') { state.after = state.after.slice(1); evt.preventDefault(); return true; } } else { state.selection = ''; evt.preventDefault(); return true; } return false; }), new Keystroke((evt, state, editor) => { if (evt.which !== 37 /* left arrow */ && evt.which !== 39 /* right arrow */) { return false; } const isJump = (cledit.Utils.isMac && evt.altKey) || (!cledit.Utils.isMac && evt.ctrlKey); if (!isJump) { return false; } // Custom jump behavior const textContent = editor.getContent(); const offset = getNextWordOffset( textContent, editor.selectionMgr.selectionEnd, evt.which === 37, ); if (evt.shiftKey) { // rebuild the state completely const min = Math.min(editor.selectionMgr.selectionStart, offset); const max = Math.max(editor.selectionMgr.selectionStart, offset); state.before = textContent.slice(0, min); state.after = textContent.slice(max); state.selection = textContent.slice(min, max); state.isBackwardSelection = editor.selectionMgr.selectionStart > offset; } else { state.before = textContent.slice(0, offset); state.after = textContent.slice(offset); state.selection = ''; } evt.preventDefault(); return true; }), ]; ================================================ FILE: src/services/editor/cledit/cleditMarker.js ================================================ import cledit from './cleditCore'; const DIFF_DELETE = -1; const DIFF_INSERT = 1; const DIFF_EQUAL = 0; let idCounter = 0; class Marker { constructor(offset, trailing) { this.id = idCounter; idCounter += 1; this.offset = offset; this.trailing = trailing; } adjustOffset(diffs) { let startOffset = 0; diffs.cl_each((diff) => { const diffType = diff[0]; const diffText = diff[1]; const diffOffset = diffText.length; switch (diffType) { case DIFF_EQUAL: startOffset += diffOffset; break; case DIFF_INSERT: if ( this.trailing ? this.offset > startOffset : this.offset >= startOffset ) { this.offset += diffOffset; } startOffset += diffOffset; break; case DIFF_DELETE: if (this.offset > startOffset) { this.offset -= Math.min(diffOffset, this.offset - startOffset); } break; default: } }); } } cledit.Marker = Marker; ================================================ FILE: src/services/editor/cledit/cleditSelectionMgr.js ================================================ import cledit from './cleditCore'; function SelectionMgr(editor) { const { debounce } = cledit.Utils; const contentElt = editor.$contentElt; const scrollElt = editor.$scrollElt; cledit.Utils.createEventHooks(this); let lastSelectionStart = 0; let lastSelectionEnd = 0; this.selectionStart = 0; this.selectionEnd = 0; this.cursorCoordinates = {}; this.findContainer = (offset) => { const result = cledit.Utils.findContainer(contentElt, offset); if (result.container.nodeValue === '\n') { const hdLfElt = result.container.parentNode; if (hdLfElt.className === 'hd-lf' && hdLfElt.previousSibling && hdLfElt.previousSibling.tagName === 'BR') { result.container = hdLfElt.parentNode; result.offsetInContainer = Array.prototype.indexOf.call( result.container.childNodes, result.offsetInContainer === 0 ? hdLfElt.previousSibling : hdLfElt, ); } } return result; }; this.createRange = (start, end) => { const range = document.createRange(); const startContainer = typeof start === 'number' ? this.findContainer(start < 0 ? 0 : start) : start; let endContainer = startContainer; if (start !== end) { endContainer = typeof end === 'number' ? this.findContainer(end < 0 ? 0 : end) : end; } range.setStart(startContainer.container, startContainer.offsetInContainer); range.setEnd(endContainer.container, endContainer.offsetInContainer); return range; }; let adjustScroll; const debouncedUpdateCursorCoordinates = debounce(() => { const coordinates = this.getCoordinates( this.selectionEnd, this.selectionEndContainer, this.selectionEndOffset, ); if (this.cursorCoordinates.top !== coordinates.top || this.cursorCoordinates.height !== coordinates.height || this.cursorCoordinates.left !== coordinates.left ) { this.cursorCoordinates = coordinates; this.$trigger('cursorCoordinatesChanged', coordinates); } if (adjustScroll) { let scrollEltHeight = scrollElt.clientHeight; if (typeof adjustScroll === 'number') { scrollEltHeight -= adjustScroll; } const adjustment = (scrollEltHeight / 2) * editor.options.getCursorFocusRatio(); let cursorTop = this.cursorCoordinates.top + (this.cursorCoordinates.height / 2); // Adjust cursorTop with contentElt position relative to scrollElt cursorTop += (contentElt.getBoundingClientRect().top - scrollElt.getBoundingClientRect().top) + scrollElt.scrollTop; const minScrollTop = cursorTop - adjustment; const maxScrollTop = (cursorTop + adjustment) - scrollEltHeight; if (scrollElt.scrollTop > minScrollTop) { scrollElt.scrollTop = minScrollTop; } else if (scrollElt.scrollTop < maxScrollTop) { scrollElt.scrollTop = maxScrollTop; } } adjustScroll = false; }); this.updateCursorCoordinates = (adjustScrollParam) => { adjustScroll = adjustScroll || adjustScrollParam; debouncedUpdateCursorCoordinates(); }; let oldSelectionRange; const checkSelection = (selectionRange) => { if (!oldSelectionRange || oldSelectionRange.startContainer !== selectionRange.startContainer || oldSelectionRange.startOffset !== selectionRange.startOffset || oldSelectionRange.endContainer !== selectionRange.endContainer || oldSelectionRange.endOffset !== selectionRange.endOffset ) { oldSelectionRange = selectionRange; this.$trigger('selectionChanged', this.selectionStart, this.selectionEnd, selectionRange); return true; } return false; }; this.hasFocus = () => contentElt === document.activeElement; this.restoreSelection = () => { const min = Math.min(this.selectionStart, this.selectionEnd); const max = Math.max(this.selectionStart, this.selectionEnd); const selectionRange = this.createRange(min, max); if (!document.contains(selectionRange.commonAncestorContainer)) { return null; } const selection = window.getSelection(); selection.removeAllRanges(); const isBackward = this.selectionStart > this.selectionEnd; if (isBackward && selection.extend) { const beginRange = selectionRange.cloneRange(); beginRange.collapse(false); selection.addRange(beginRange); selection.extend(selectionRange.startContainer, selectionRange.startOffset); } else { selection.addRange(selectionRange); } checkSelection(selectionRange); return selectionRange; }; const saveLastSelection = debounce(() => { lastSelectionStart = this.selectionStart; lastSelectionEnd = this.selectionEnd; }, 50); const setSelection = (start = this.selectionStart, end = this.selectionEnd) => { this.selectionStart = start < 0 ? 0 : start; this.selectionEnd = end < 0 ? 0 : end; saveLastSelection(); }; this.setSelectionStartEnd = (start, end, restoreSelection = true) => { setSelection(start, end); if (restoreSelection && this.hasFocus()) { return this.restoreSelection(); } return null; }; this.saveSelectionState = (() => { // Credit: https://github.com/timdown/rangy function arrayContains(arr, val) { let i = arr.length; while (i) { i -= 1; if (arr[i] === val) { return true; } } return false; } function getClosestAncestorIn(node, ancestor, selfIsAncestor) { let p; let n = selfIsAncestor ? node : node.parentNode; while (n) { p = n.parentNode; if (p === ancestor) { return n; } n = p; } return null; } function getNodeIndex(node) { let i = 0; let { previousSibling } = node; while (previousSibling) { i += 1; ({ previousSibling } = previousSibling); } return i; } function getCommonAncestor(node1, node2) { const ancestors = []; let n; for (n = node1; n; n = n.parentNode) { ancestors.push(n); } for (n = node2; n; n = n.parentNode) { if (arrayContains(ancestors, n)) { return n; } } return null; } function comparePoints(nodeA, offsetA, nodeB, offsetB) { // See http://www.w3.org/TR/DOM-Level-2-Traversal-Range/ranges.html#Level-2-Range-Comparing let n; if (nodeA === nodeB) { // Case 1: nodes are the same if (offsetA === offsetB) { return 0; } return offsetA < offsetB ? -1 : 1; } let nodeC = getClosestAncestorIn(nodeB, nodeA, true); if (nodeC) { // Case 2: node C (container B or an ancestor) is a child node of A return offsetA <= getNodeIndex(nodeC) ? -1 : 1; } nodeC = getClosestAncestorIn(nodeA, nodeB, true); if (nodeC) { // Case 3: node C (container A or an ancestor) is a child node of B return getNodeIndex(nodeC) < offsetB ? -1 : 1; } const root = getCommonAncestor(nodeA, nodeB); if (!root) { throw new Error('comparePoints error: nodes have no common ancestor'); } // Case 4: containers are siblings or descendants of siblings const childA = (nodeA === root) ? root : getClosestAncestorIn(nodeA, root, true); const childB = (nodeB === root) ? root : getClosestAncestorIn(nodeB, root, true); if (childA === childB) { // This shouldn't be possible throw module.createError('comparePoints got to case 4 and childA and childB are the same!'); } n = root.firstChild; while (n) { if (n === childA) { return -1; } else if (n === childB) { return 1; } n = n.nextSibling; } return 0; } const save = () => { let result; if (this.hasFocus()) { let { selectionStart } = this; let { selectionEnd } = this; const selection = window.getSelection(); if (selection.rangeCount > 0) { const selectionRange = selection.getRangeAt(0); let node = selectionRange.startContainer; // eslint-disable-next-line no-bitwise if ((contentElt.compareDocumentPosition(node) & window.Node.DOCUMENT_POSITION_CONTAINED_BY) || contentElt === node ) { let offset = selectionRange.startOffset; if (node.firstChild && offset > 0) { node = node.childNodes[offset - 1]; offset = node.textContent.length; } let container = node; while (node !== contentElt) { node = node.previousSibling; while (node) { offset += (node.textContent || '').length; node = node.previousSibling; } node = container.parentNode; container = node; } let selectionText = `${selectionRange}`; // Fix end of line when only br is selected const brElt = selectionRange.endContainer.firstChild; if (brElt && brElt.tagName === 'BR' && selectionRange.endOffset === 1) { selectionText += '\n'; } if (comparePoints( selection.anchorNode, selection.anchorOffset, selection.focusNode, selection.focusOffset, ) === 1) { selectionStart = offset + selectionText.length; selectionEnd = offset; } else { selectionStart = offset; selectionEnd = offset + selectionText.length; } if (selectionStart === selectionEnd && selectionStart === editor.getContent().length) { // If cursor is after the trailingNode selectionEnd -= 1; selectionStart = selectionEnd; result = this.setSelectionStartEnd(selectionStart, selectionEnd); } else { setSelection(selectionStart, selectionEnd); result = checkSelection(selectionRange); // selectionRange doesn't change when selection is at the start of a section result = result || lastSelectionStart !== this.selectionStart; } } } } return result; }; const saveCheckChange = () => save() && ( lastSelectionStart !== this.selectionStart || lastSelectionEnd !== this.selectionEnd); let nextTickAdjustScroll = false; const longerDebouncedSave = debounce(() => { this.updateCursorCoordinates(saveCheckChange() && nextTickAdjustScroll); nextTickAdjustScroll = false; }, 10); const debouncedSave = debounce(() => { this.updateCursorCoordinates(saveCheckChange() && nextTickAdjustScroll); // In some cases we have to wait a little longer to see the // selection change (Cmd+A on Chrome OSX) longerDebouncedSave(); }); return (debounced, adjustScrollParam, forceAdjustScroll) => { if (forceAdjustScroll) { lastSelectionStart = undefined; lastSelectionEnd = undefined; } if (debounced) { nextTickAdjustScroll = nextTickAdjustScroll || adjustScrollParam; debouncedSave(); } else { save(); } }; })(); this.getSelectedText = () => { const min = Math.min(this.selectionStart, this.selectionEnd); const max = Math.max(this.selectionStart, this.selectionEnd); return editor.getContent().substring(min, max); }; this.getCoordinates = (inputOffset, containerParam, offsetInContainerParam) => { let container = containerParam; let offsetInContainer = offsetInContainerParam; if (!container) { const offset = this.findContainer(inputOffset); ({ container } = offset); ({ offsetInContainer } = offset); } let containerElt = container; if (!containerElt.hasChildNodes() && container.parentNode) { containerElt = container.parentNode; } let isInvisible = false; while (!containerElt.offsetHeight) { isInvisible = true; if (containerElt.previousSibling) { containerElt = containerElt.previousSibling; } else { containerElt = containerElt.parentNode; if (!containerElt) { return { top: 0, height: 0, left: 0, }; } } } let rect; let left = 'left'; if (isInvisible || container.textContent === '\n') { rect = containerElt.getBoundingClientRect(); } else { const selectedChar = editor.getContent()[inputOffset]; let startOffset = { container, offsetInContainer, }; let endOffset = { container, offsetInContainer, }; if (inputOffset > 0 && (selectedChar === undefined || selectedChar === '\n')) { left = 'right'; if (startOffset.offsetInContainer === 0) { // Need to calculate offset-1 startOffset = inputOffset - 1; } else { startOffset.offsetInContainer -= 1; } } else if (endOffset.offsetInContainer === container.textContent.length) { // Need to calculate offset+1 endOffset = inputOffset + 1; } else { endOffset.offsetInContainer += 1; } const range = this.createRange(startOffset, endOffset); rect = range.getBoundingClientRect(); } const contentRect = contentElt.getBoundingClientRect(); return { top: Math.round((rect.top - contentRect.top) + contentElt.scrollTop), height: Math.round(rect.height), left: Math.round((rect[left] - contentRect.left) + contentElt.scrollLeft), }; }; this.getClosestWordOffset = (offset) => { let offsetStart = 0; let offsetEnd = 0; let nextOffset = 0; editor.getContent().split(/\s/).cl_some((word) => { if (word) { offsetStart = nextOffset; offsetEnd = nextOffset + word.length; if (offsetEnd > offset) { return true; } } nextOffset += word.length + 1; return false; }); return { start: offsetStart, end: offsetEnd, }; }; } cledit.SelectionMgr = SelectionMgr; ================================================ FILE: src/services/editor/cledit/cleditUndoMgr.js ================================================ import DiffMatchPatch from 'diff-match-patch'; import cledit from './cleditCore'; function UndoMgr(editor) { cledit.Utils.createEventHooks(this); /* eslint-disable new-cap */ const diffMatchPatch = new DiffMatchPatch(); /* eslint-enable new-cap */ const self = this; let selectionMgr; const undoStack = []; const redoStack = []; let currentState; let previousPatches = []; let currentPatches = []; const { debounce } = cledit.Utils; this.options = { undoStackMaxSize: 200, bufferStateUntilIdle: 1000, patchHandler: { makePatches(oldContent, newContent, diffs) { return diffMatchPatch.patch_make(oldContent, diffs); }, applyPatches(patches, content) { return diffMatchPatch.patch_apply(patches, content)[0]; }, reversePatches(patches) { const reversedPatches = diffMatchPatch.patch_deepCopy(patches).reverse(); reversedPatches.cl_each((patch) => { patch.diffs.cl_each((diff) => { diff[0] = -diff[0]; }); }); return reversedPatches; }, }, }; let stateMgr; function StateMgr() { let currentTime; let lastTime; let lastMode; this.isBufferState = () => { currentTime = Date.now(); return this.currentMode !== 'single' && this.currentMode === lastMode && currentTime - lastTime < self.options.bufferStateUntilIdle; }; this.setDefaultMode = (mode) => { this.currentMode = this.currentMode || mode; }; this.resetMode = () => { stateMgr.currentMode = undefined; lastMode = undefined; }; this.saveMode = () => { lastMode = this.currentMode; this.currentMode = undefined; lastTime = currentTime; }; } class State { addToUndoStack() { undoStack.push(this); this.patches = previousPatches; previousPatches = []; } addToRedoStack() { redoStack.push(this); this.patches = previousPatches; previousPatches = []; } } stateMgr = new StateMgr(); this.setCurrentMode = (mode) => { stateMgr.currentMode = mode; }; this.setDefaultMode = stateMgr.setDefaultMode.cl_bind(stateMgr); this.addDiffs = (oldContent, newContent, diffs) => { const patches = this.options.patchHandler.makePatches(oldContent, newContent, diffs); patches.cl_each(patch => currentPatches.push(patch)); }; function saveCurrentPatches() { // Move currentPatches into previousPatches Array.prototype.push.apply(previousPatches, currentPatches); currentPatches = []; } this.saveState = debounce(() => { redoStack.length = 0; if (!stateMgr.isBufferState()) { currentState.addToUndoStack(); // Limit the size of the stack while (undoStack.length > this.options.undoStackMaxSize) { undoStack.shift(); } } saveCurrentPatches(); currentState = new State(); stateMgr.saveMode(); this.$trigger('undoStateChange'); }); this.canUndo = () => !!undoStack.length; this.canRedo = () => !!redoStack.length; const restoreState = (patchesParam, isForward) => { let patches = patchesParam; // Update editor const content = editor.getContent(); if (!isForward) { patches = this.options.patchHandler.reversePatches(patches); } const newContent = this.options.patchHandler.applyPatches(patches, content); const newContentText = newContent.text || newContent; const range = editor.setContent(newContentText, true); const selection = newContent.selection || { start: range.end, end: range.end, }; selectionMgr.setSelectionStartEnd(selection.start, selection.end); selectionMgr.updateCursorCoordinates(true); stateMgr.resetMode(); this.$trigger('undoStateChange'); editor.adjustCursorPosition(); }; this.undo = () => { const state = undoStack.pop(); if (!state) { return; } saveCurrentPatches(); currentState.addToRedoStack(); restoreState(currentState.patches); previousPatches = state.patches; currentState = state; }; this.redo = () => { const state = redoStack.pop(); if (!state) { return; } currentState.addToUndoStack(); restoreState(state.patches, true); previousPatches = state.patches; currentState = state; }; this.init = (options) => { this.options.cl_extend(options || {}); ({ selectionMgr } = editor); if (!currentState) { currentState = new State(); } }; } cledit.UndoMgr = UndoMgr; ================================================ FILE: src/services/editor/cledit/cleditUtils.js ================================================ import cledit from './cleditCore'; const Utils = { isGecko: 'MozAppearance' in document.documentElement.style, isWebkit: 'WebkitAppearance' in document.documentElement.style, isMsie: 'msTransform' in document.documentElement.style, isMac: navigator.userAgent.indexOf('Mac OS X') !== -1, }; // Faster than setTimeout(0). Credit: https://github.com/stefanpenner/es6-promise Utils.defer = (() => { const queue = new Array(1000); let queueLength = 0; function flush() { for (let i = 0; i < queueLength; i += 1) { try { queue[i](); } catch (e) { // eslint-disable-next-line no-console console.error(e.message, e.stack); } queue[i] = undefined; } queueLength = 0; } let iterations = 0; const observer = new window.MutationObserver(flush); const node = document.createTextNode(''); observer.observe(node, { characterData: true }); return (fn) => { queue[queueLength] = fn; queueLength += 1; if (queueLength === 1) { iterations = (iterations + 1) % 2; node.data = iterations; } }; })(); Utils.debounce = (func, wait) => { let timeoutId; let isExpected; return wait ? () => { clearTimeout(timeoutId); timeoutId = setTimeout(func, wait); } : () => { if (!isExpected) { isExpected = true; Utils.defer(() => { isExpected = false; func(); }); } }; }; Utils.createEventHooks = (object) => { const listenerMap = Object.create(null); object.$trigger = (eventType, ...args) => { const listeners = listenerMap[eventType]; if (listeners) { listeners.cl_each((listener) => { try { listener.apply(object, args); } catch (e) { // eslint-disable-next-line no-console console.error(e.message, e.stack); } }); } }; object.on = (eventType, listener) => { let listeners = listenerMap[eventType]; if (!listeners) { listeners = []; listenerMap[eventType] = listeners; } listeners.push(listener); }; object.off = (eventType, listener) => { const listeners = listenerMap[eventType]; if (listeners) { const index = listeners.indexOf(listener); if (index !== -1) { listeners.splice(index, 1); } } }; }; Utils.findContainer = (elt, offset) => { let containerOffset = 0; let container; let child = elt; do { container = child; child = child.firstChild; if (child) { do { const len = child.textContent.length; if (containerOffset <= offset && containerOffset + len > offset) { break; } containerOffset += len; child = child.nextSibling; } while (child); } } while (child && child.firstChild && child.nodeType !== 3); if (child) { return { container: child, offsetInContainer: offset - containerOffset, }; } while (container.lastChild) { container = container.lastChild; } return { container, offsetInContainer: container.nodeType === 3 ? container.textContent.length : 0, }; }; cledit.Utils = Utils; ================================================ FILE: src/services/editor/cledit/cleditWatcher.js ================================================ import cledit from './cleditCore'; function Watcher(editor, listener) { this.isWatching = false; let contentObserver; this.startWatching = () => { this.stopWatching(); this.isWatching = true; contentObserver = new window.MutationObserver(listener); contentObserver.observe(editor.$contentElt, { childList: true, subtree: true, characterData: true, }); }; this.stopWatching = () => { if (contentObserver) { contentObserver.disconnect(); contentObserver = undefined; } this.isWatching = false; }; this.noWatch = (cb) => { if (this.isWatching === true) { this.stopWatching(); cb(); this.startWatching(); } else { cb(); } }; } cledit.Watcher = Watcher; ================================================ FILE: src/services/editor/cledit/index.js ================================================ import '../../../libs/clunderscore'; import cledit from './cleditCore'; import './cleditHighlighter'; import './cleditKeystroke'; import './cleditMarker'; import './cleditSelectionMgr'; import './cleditUndoMgr'; import './cleditUtils'; import './cleditWatcher'; export default cledit; ================================================ FILE: src/services/editor/editorSvcDiscussions.js ================================================ import DiffMatchPatch from 'diff-match-patch'; import cledit from './cledit'; import utils from '../utils'; import diffUtils from '../diffUtils'; import store from '../../store'; import EditorClassApplier from '../../components/common/EditorClassApplier'; import PreviewClassApplier from '../../components/common/PreviewClassApplier'; let clEditor; // let discussionIds = {}; let discussionMarkers = {}; let markerKeys; let markerIdxMap; let previousPatchableText; let currentPatchableText; let isChangePatch; let contentId; let editorClassAppliers = {}; let previewClassAppliers = {}; function getDiscussionMarkers(discussion, discussionId, onMarker) { const getMarker = (offsetName) => { const markerKey = `${discussionId}:${offsetName}`; let marker = discussionMarkers[markerKey]; if (!marker) { marker = new cledit.Marker(discussion[offsetName], offsetName === 'end'); marker.discussionId = discussionId; marker.offsetName = offsetName; clEditor.addMarker(marker); discussionMarkers[markerKey] = marker; } onMarker(marker); }; getMarker('start'); getMarker('end'); } function syncDiscussionMarkers(content, writeOffsets) { const discussions = { ...content.discussions, }; const newDiscussion = store.getters['discussion/newDiscussion']; if (newDiscussion) { discussions[store.state.discussion.newDiscussionId] = { ...newDiscussion, }; } Object.entries(discussionMarkers).forEach(([markerKey, marker]) => { // Remove marker if discussion was removed const discussion = discussions[marker.discussionId]; if (!discussion) { clEditor.removeMarker(marker); delete discussionMarkers[markerKey]; } }); Object.entries(discussions).forEach(([discussionId, discussion]) => { getDiscussionMarkers(discussion, discussionId, writeOffsets ? (marker) => { discussion[marker.offsetName] = marker.offset; } : (marker) => { marker.offset = discussion[marker.offsetName]; }); }); if (writeOffsets && newDiscussion) { store.commit( 'discussion/patchNewDiscussion', discussions[store.state.discussion.newDiscussionId], ); } } function removeDiscussionMarkers() { Object.entries(discussionMarkers).forEach(([, marker]) => { clEditor.removeMarker(marker); }); discussionMarkers = {}; markerKeys = []; markerIdxMap = Object.create(null); } const diffMatchPatch = new DiffMatchPatch(); function makePatches() { const diffs = diffMatchPatch.diff_main(previousPatchableText, currentPatchableText); return diffMatchPatch.patch_make(previousPatchableText, diffs); } function applyPatches(patches) { const newPatchableText = diffMatchPatch.patch_apply(patches, currentPatchableText)[0]; let result = newPatchableText; if (markerKeys.length) { // Strip text markers result = result.replace(new RegExp(`[\ue000-${String.fromCharCode((0xe000 + markerKeys.length) - 1)}]`, 'g'), ''); } // Expect a `contentChanged` event if (result !== clEditor.getContent()) { previousPatchableText = currentPatchableText; currentPatchableText = newPatchableText; isChangePatch = true; } return result; } function reversePatches(patches) { const result = diffMatchPatch.patch_deepCopy(patches).reverse(); result.forEach((patch) => { patch.diffs.forEach((diff) => { diff[0] = -diff[0]; }); }); return result; } export default { createClEditor(editorElt) { this.clEditor = cledit(editorElt, editorElt.parentNode, true); ({ clEditor } = this); clEditor.on('contentChanged', (text) => { const oldContent = store.getters['content/current']; const newContent = { ...utils.deepCopy(oldContent), text: utils.sanitizeText(text), }; syncDiscussionMarkers(newContent, true); if (!isChangePatch) { previousPatchableText = currentPatchableText; currentPatchableText = diffUtils.makePatchableText(newContent, markerKeys, markerIdxMap); } else { // Take a chance to restore discussion offsets on undo/redo newContent.text = currentPatchableText; diffUtils.restoreDiscussionOffsets(newContent, markerKeys); syncDiscussionMarkers(newContent, false); } store.dispatch('content/patchCurrent', newContent); isChangePatch = false; }); clEditor.on('focus', () => store.commit('discussion/setNewCommentFocus', false)); }, initClEditorInternal(opts) { const content = store.getters['content/current']; if (content) { removeDiscussionMarkers(); // Markers will be recreated on contentChanged const contentState = store.getters['contentState/current']; const options = Object.assign({ selectionStart: contentState.selectionStart, selectionEnd: contentState.selectionEnd, patchHandler: { makePatches, applyPatches, reversePatches, }, }, opts); if (contentId !== content.id) { contentId = content.id; currentPatchableText = diffUtils.makePatchableText(content, markerKeys, markerIdxMap); previousPatchableText = currentPatchableText; syncDiscussionMarkers(content, false); options.content = content.text; } clEditor.init(options); } }, applyContent() { if (clEditor) { const content = store.getters['content/current']; if (clEditor.setContent(content.text, true).range) { // Marker will be recreated on contentChange removeDiscussionMarkers(); } else { syncDiscussionMarkers(content, false); } } }, getTrimmedSelection() { const { selectionMgr } = clEditor; let start = Math.min(selectionMgr.selectionStart, selectionMgr.selectionEnd); let end = Math.max(selectionMgr.selectionStart, selectionMgr.selectionEnd); const text = clEditor.getContent(); while ((text[start] || '').match(/\s/)) { start += 1; } while ((text[end - 1] || '').match(/\s/)) { end -= 1; } return start < end && { start, end }; }, initHighlighters() { store.watch( () => store.getters['discussion/newDiscussion'], () => syncDiscussionMarkers(store.getters['content/current'], false), ); store.watch( () => store.getters['discussion/currentFileDiscussions'], (discussions) => { const classGetter = (type, discussionId) => () => { const classes = [`discussion-${type}-highlighting--${discussionId}`, `discussion-${type}-highlighting`]; if (store.state.discussion.currentDiscussionId === discussionId) { classes.push(`discussion-${type}-highlighting--selected`); } return classes; }; const offsetGetter = discussionId => () => { const startMarker = discussionMarkers[`${discussionId}:start`]; const endMarker = discussionMarkers[`${discussionId}:end`]; return startMarker && endMarker && { start: startMarker.offset, end: endMarker.offset, }; }; // Editor class appliers const oldEditorClassAppliers = editorClassAppliers; editorClassAppliers = {}; Object.keys(discussions).forEach((discussionId) => { const classApplier = oldEditorClassAppliers[discussionId] || new EditorClassApplier( classGetter('editor', discussionId), offsetGetter(discussionId), { discussionId }, ); editorClassAppliers[discussionId] = classApplier; }); // Clean unused class appliers Object.entries(oldEditorClassAppliers).forEach(([discussionId, classApplier]) => { if (!editorClassAppliers[discussionId]) { classApplier.stop(); } }); // Preview class appliers const oldPreviewClassAppliers = previewClassAppliers; previewClassAppliers = {}; Object.keys(discussions).forEach((discussionId) => { const classApplier = oldPreviewClassAppliers[discussionId] || new PreviewClassApplier( classGetter('preview', discussionId), offsetGetter(discussionId), { discussionId }, ); previewClassAppliers[discussionId] = classApplier; }); // Clean unused class appliers Object.entries(oldPreviewClassAppliers).forEach(([discussionId, classApplier]) => { if (!previewClassAppliers[discussionId]) { classApplier.stop(); } }); }, ); }, }; ================================================ FILE: src/services/editor/editorSvcUtils.js ================================================ import DiffMatchPatch from 'diff-match-patch'; import cledit from './cledit'; import animationSvc from '../animationSvc'; import store from '../../store'; const diffMatchPatch = new DiffMatchPatch(); export default { /** * Get an object describing the position of the scroll bar in the file. */ getScrollPosition(elt = store.getters['layout/styles'].showEditor ? this.editorElt : this.previewElt) { const dimensionKey = elt === this.editorElt ? 'editorDimension' : 'previewDimension'; const { scrollTop } = elt.parentNode; let result; if (this.previewCtxMeasured) { this.previewCtxMeasured.sectionDescList.some((sectionDesc, sectionIdx) => { if (scrollTop >= sectionDesc[dimensionKey].endOffset) { return false; } const posInSection = (scrollTop - sectionDesc[dimensionKey].startOffset) / (sectionDesc[dimensionKey].height || 1); result = { sectionIdx, posInSection, }; return true; }); } return result; }, /** * Restore the scroll position from the current file content state. */ restoreScrollPosition() { const { scrollPosition } = store.getters['contentState/current']; if (scrollPosition && this.previewCtxMeasured) { const sectionDesc = this.previewCtxMeasured.sectionDescList[scrollPosition.sectionIdx]; if (sectionDesc) { const editorScrollTop = sectionDesc.editorDimension.startOffset + (sectionDesc.editorDimension.height * scrollPosition.posInSection); this.editorElt.parentNode.scrollTop = Math.floor(editorScrollTop); const previewScrollTop = sectionDesc.previewDimension.startOffset + (sectionDesc.previewDimension.height * scrollPosition.posInSection); this.previewElt.parentNode.scrollTop = Math.floor(previewScrollTop); } } }, /** * Get the offset in the preview corresponding to the offset of the markdown in the editor */ getPreviewOffset( editorOffset, sectionDescList = (this.previewCtxWithDiffs || {}).sectionDescList, ) { if (!sectionDescList) { return null; } let offset = editorOffset; let previewOffset = 0; sectionDescList.some((sectionDesc) => { if (!sectionDesc.textToPreviewDiffs) { previewOffset = null; return true; } if (sectionDesc.section.text.length >= offset) { previewOffset += diffMatchPatch.diff_xIndex(sectionDesc.textToPreviewDiffs, offset); return true; } offset -= sectionDesc.section.text.length; previewOffset += sectionDesc.previewText.length; return false; }); return previewOffset; }, /** * Get the offset of the markdown in the editor corresponding to the offset in the preview */ getEditorOffset( previewOffset, sectionDescList = (this.previewCtxWithDiffs || {}).sectionDescList, ) { if (!sectionDescList) { return null; } let offset = previewOffset; let editorOffset = 0; sectionDescList.some((sectionDesc) => { if (!sectionDesc.textToPreviewDiffs) { editorOffset = null; return true; } if (sectionDesc.previewText.length >= offset) { const previewToTextDiffs = sectionDesc.textToPreviewDiffs .map(diff => [-diff[0], diff[1]]); editorOffset += diffMatchPatch.diff_xIndex(previewToTextDiffs, offset); return true; } offset -= sectionDesc.previewText.length; editorOffset += sectionDesc.section.text.length; return false; }); return editorOffset; }, /** * Get the coordinates of an offset in the preview */ getPreviewOffsetCoordinates(offset) { const start = cledit.Utils.findContainer(this.previewElt, offset && offset - 1); const end = cledit.Utils.findContainer(this.previewElt, offset || offset + 1); const range = document.createRange(); range.setStart(start.container, start.offsetInContainer); range.setEnd(end.container, end.offsetInContainer); const rect = range.getBoundingClientRect(); const contentRect = this.previewElt.getBoundingClientRect(); return { top: Math.round((rect.top - contentRect.top) + this.previewElt.scrollTop), height: Math.round(rect.height), left: Math.round((rect.right - contentRect.left) + this.previewElt.scrollLeft), }; }, /** * Scroll the preview (or the editor if preview is hidden) to the specified anchor */ scrollToAnchor(anchor) { let scrollTop = 0; const scrollerElt = this.previewElt.parentNode; const elt = document.getElementById(anchor); if (elt) { scrollTop = elt.offsetTop; } const maxScrollTop = scrollerElt.scrollHeight - scrollerElt.offsetHeight; if (scrollTop < 0) { scrollTop = 0; } else if (scrollTop > maxScrollTop) { scrollTop = maxScrollTop; } animationSvc.animate(scrollerElt) .scrollTop(scrollTop) .duration(360) .start(); }, }; ================================================ FILE: src/services/editor/sectionUtils.js ================================================ class SectionDimension { constructor(startOffset, endOffset) { this.startOffset = startOffset; this.endOffset = endOffset; this.height = endOffset - startOffset; } } const dimensionNormalizer = dimensionName => (editorSvc) => { const dimensionList = editorSvc.previewCtx.sectionDescList .map(sectionDesc => sectionDesc[dimensionName]); let dimension; let i; let j; for (i = 0; i < dimensionList.length; i += 1) { dimension = dimensionList[i]; if (dimension.height) { for (j = i + 1; j < dimensionList.length && dimensionList[j].height === 0; j += 1) { // Loop } const normalizeFactor = j - i; if (normalizeFactor !== 1) { const normalizedHeight = dimension.height / normalizeFactor; dimension.height = normalizedHeight; dimension.endOffset = dimension.startOffset + dimension.height; for (j = i + 1; j < i + normalizeFactor; j += 1) { const startOffset = dimension.endOffset; dimension = dimensionList[j]; dimension.startOffset = startOffset; dimension.height = normalizedHeight; dimension.endOffset = dimension.startOffset + dimension.height; } i = j - 1; } } } }; const normalizeEditorDimensions = dimensionNormalizer('editorDimension'); const normalizePreviewDimensions = dimensionNormalizer('previewDimension'); const normalizeTocDimensions = dimensionNormalizer('tocDimension'); export default { measureSectionDimensions(editorSvc) { let editorSectionOffset = 0; let previewSectionOffset = 0; let tocSectionOffset = 0; let sectionDesc = editorSvc.previewCtx.sectionDescList[0]; let nextSectionDesc; let i = 1; for (; i < editorSvc.previewCtx.sectionDescList.length; i += 1) { nextSectionDesc = editorSvc.previewCtx.sectionDescList[i]; // Measure editor section let newEditorSectionOffset = nextSectionDesc.editorElt ? nextSectionDesc.editorElt.offsetTop : editorSectionOffset; newEditorSectionOffset = newEditorSectionOffset > editorSectionOffset ? newEditorSectionOffset : editorSectionOffset; sectionDesc.editorDimension = new SectionDimension( editorSectionOffset, newEditorSectionOffset, ); editorSectionOffset = newEditorSectionOffset; // Measure preview section let newPreviewSectionOffset = nextSectionDesc.previewElt ? nextSectionDesc.previewElt.offsetTop : previewSectionOffset; newPreviewSectionOffset = newPreviewSectionOffset > previewSectionOffset ? newPreviewSectionOffset : previewSectionOffset; sectionDesc.previewDimension = new SectionDimension( previewSectionOffset, newPreviewSectionOffset, ); previewSectionOffset = newPreviewSectionOffset; // Measure TOC section let newTocSectionOffset = nextSectionDesc.tocElt ? nextSectionDesc.tocElt.offsetTop + (nextSectionDesc.tocElt.offsetHeight / 2) : tocSectionOffset; newTocSectionOffset = newTocSectionOffset > tocSectionOffset ? newTocSectionOffset : tocSectionOffset; sectionDesc.tocDimension = new SectionDimension(tocSectionOffset, newTocSectionOffset); tocSectionOffset = newTocSectionOffset; sectionDesc = nextSectionDesc; } // Last section sectionDesc = editorSvc.previewCtx.sectionDescList[i - 1]; if (sectionDesc) { sectionDesc.editorDimension = new SectionDimension( editorSectionOffset, editorSvc.editorElt.scrollHeight, ); sectionDesc.previewDimension = new SectionDimension( previewSectionOffset, editorSvc.previewElt.scrollHeight, ); sectionDesc.tocDimension = new SectionDimension( tocSectionOffset, editorSvc.tocElt.scrollHeight, ); } normalizeEditorDimensions(editorSvc); normalizePreviewDimensions(editorSvc); normalizeTocDimensions(editorSvc); }, }; ================================================ FILE: src/services/editorSvc.js ================================================ import Vue from 'vue'; import DiffMatchPatch from 'diff-match-patch'; import Prism from 'prismjs'; import markdownItPandocRenderer from 'markdown-it-pandoc-renderer'; import cledit from './editor/cledit'; import pagedown from '../libs/pagedown'; import htmlSanitizer from '../libs/htmlSanitizer'; import markdownConversionSvc from './markdownConversionSvc'; import markdownGrammarSvc from './markdownGrammarSvc'; import sectionUtils from './editor/sectionUtils'; import extensionSvc from './extensionSvc'; import editorSvcDiscussions from './editor/editorSvcDiscussions'; import editorSvcUtils from './editor/editorSvcUtils'; import utils from './utils'; import store from '../store'; const allowDebounce = (action, wait) => { let timeoutId; return (doDebounce = false, ...params) => { clearTimeout(timeoutId); if (doDebounce) { timeoutId = setTimeout(() => action(...params), wait); } else { action(...params); } }; }; const diffMatchPatch = new DiffMatchPatch(); let instantPreview = true; let tokens; class SectionDesc { constructor(section, previewElt, tocElt, html) { this.section = section; this.editorElt = section.elt; this.previewElt = previewElt; this.tocElt = tocElt; this.html = html; } } // Use a vue instance as an event bus const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils, { // Elements editorElt: null, previewElt: null, tocElt: null, // Other objects clEditor: null, pagedownEditor: null, options: null, prismGrammars: null, converter: null, parsingCtx: null, conversionCtx: null, previewCtx: { sectionDescList: [], }, previewCtxMeasured: null, previewCtxWithDiffs: null, sectionList: null, selectionRange: null, previewSelectionRange: null, previewSelectionStartOffset: null, /** * Initialize the Prism grammar with the options */ initPrism() { const options = { ...this.options, insideFences: markdownConversionSvc.defaultOptions.insideFences, }; this.prismGrammars = markdownGrammarSvc.makeGrammars(options); }, /** * Initialize the markdown-it converter with the options */ initConverter() { this.converter = markdownConversionSvc.createConverter(this.options, true); }, /** * Initialize the cledit editor with markdown-it section parser and Prism highlighter */ initClEditor() { this.previewCtxMeasured = null; editorSvc.$emit('previewCtxMeasured', null); this.previewCtxWithDiffs = null; editorSvc.$emit('previewCtxWithDiffs', null); const options = { sectionHighlighter: section => Prism .highlight(section.text, this.prismGrammars[section.data]), sectionParser: (text) => { this.parsingCtx = markdownConversionSvc.parseSections(this.converter, text); return this.parsingCtx.sections; }, getCursorFocusRatio: () => { if (store.getters['data/layoutSettings'].focusMode) { return 1; } return 0.15; }, }; this.initClEditorInternal(options); this.restoreScrollPosition(); }, /** * Finish the conversion initiated by the section parser */ convert() { this.conversionCtx = markdownConversionSvc.convert(this.parsingCtx, this.conversionCtx); this.$emit('conversionCtx', this.conversionCtx); ({ tokens } = this.parsingCtx.markdownState); }, /** * Refresh the preview with the result of `convert()` */ async refreshPreview() { const sectionDescList = []; let sectionPreviewElt; let sectionTocElt; let sectionIdx = 0; let sectionDescIdx = 0; let insertBeforePreviewElt = this.previewElt.firstChild; let insertBeforeTocElt = this.tocElt.firstChild; let previewHtml = ''; let loadingImages = []; this.conversionCtx.htmlSectionDiff.forEach((item) => { for (let i = 0; i < item[1].length; i += 1) { const section = this.conversionCtx.sectionList[sectionIdx]; if (item[0] === 0) { let sectionDesc = this.previewCtx.sectionDescList[sectionDescIdx]; sectionDescIdx += 1; if (sectionDesc.editorElt !== section.elt) { // Force textToPreviewDiffs computation sectionDesc = new SectionDesc( section, sectionDesc.previewElt, sectionDesc.tocElt, sectionDesc.html, ); } sectionDescList.push(sectionDesc); previewHtml += sectionDesc.html; sectionIdx += 1; insertBeforePreviewElt = insertBeforePreviewElt.nextSibling; insertBeforeTocElt = insertBeforeTocElt.nextSibling; } else if (item[0] === -1) { sectionDescIdx += 1; sectionPreviewElt = insertBeforePreviewElt; insertBeforePreviewElt = insertBeforePreviewElt.nextSibling; this.previewElt.removeChild(sectionPreviewElt); sectionTocElt = insertBeforeTocElt; insertBeforeTocElt = insertBeforeTocElt.nextSibling; this.tocElt.removeChild(sectionTocElt); } else if (item[0] === 1) { const html = htmlSanitizer.sanitizeHtml(this.conversionCtx.htmlSectionList[sectionIdx]); sectionIdx += 1; // Create preview section element sectionPreviewElt = document.createElement('div'); sectionPreviewElt.className = 'cl-preview-section'; sectionPreviewElt.innerHTML = html; if (insertBeforePreviewElt) { this.previewElt.insertBefore(sectionPreviewElt, insertBeforePreviewElt); } else { this.previewElt.appendChild(sectionPreviewElt); } extensionSvc.sectionPreview(sectionPreviewElt, this.options, true); loadingImages = [ ...loadingImages, ...Array.prototype.slice.call(sectionPreviewElt.getElementsByTagName('img')), ]; // Create TOC section element sectionTocElt = document.createElement('div'); sectionTocElt.className = 'cl-toc-section'; const headingElt = sectionPreviewElt.querySelector('h1, h2, h3, h4, h5, h6'); if (headingElt) { const clonedElt = headingElt.cloneNode(true); clonedElt.removeAttribute('id'); sectionTocElt.appendChild(clonedElt); } if (insertBeforeTocElt) { this.tocElt.insertBefore(sectionTocElt, insertBeforeTocElt); } else { this.tocElt.appendChild(sectionTocElt); } previewHtml += html; sectionDescList.push(new SectionDesc(section, sectionPreviewElt, sectionTocElt, html)); } } }); this.tocElt.classList[ this.tocElt.querySelector('.cl-toc-section *') ? 'remove' : 'add' ]('toc-tab--empty'); this.previewCtx = { markdown: this.conversionCtx.text, html: previewHtml.replace(/^\s+|\s+$/g, ''), text: this.previewElt.textContent, sectionDescList, }; this.$emit('previewCtx', this.previewCtx); this.makeTextToPreviewDiffs(); // Wait for images to load const loadedPromises = loadingImages.map(imgElt => new Promise((resolve) => { if (!imgElt.src) { resolve(); return; } const img = new window.Image(); img.onload = resolve; img.onerror = resolve; img.src = imgElt.src; })); await Promise.all(loadedPromises); // Debounce if sections have already been measured this.measureSectionDimensions(!!this.previewCtxMeasured); }, /** * Measure the height of each section in editor, preview and toc. */ measureSectionDimensions: allowDebounce((restoreScrollPosition = false, force = false) => { if (force || editorSvc.previewCtx !== editorSvc.previewCtxMeasured) { sectionUtils.measureSectionDimensions(editorSvc); editorSvc.previewCtxMeasured = editorSvc.previewCtx; if (restoreScrollPosition) { editorSvc.restoreScrollPosition(); } editorSvc.$emit('previewCtxMeasured', editorSvc.previewCtxMeasured); } }, 500), /** * Compute the diffs between editor's markdown and preview's html * asynchronously unless there is only one section to compute. */ makeTextToPreviewDiffs() { if (editorSvc.previewCtx !== editorSvc.previewCtxWithDiffs) { const makeOne = () => { let hasOne = false; const hasMore = editorSvc.previewCtx.sectionDescList .some((sectionDesc) => { if (!sectionDesc.textToPreviewDiffs) { if (hasOne) { return true; } if (!sectionDesc.previewText) { sectionDesc.previewText = sectionDesc.previewElt.textContent; } sectionDesc.textToPreviewDiffs = diffMatchPatch.diff_main( sectionDesc.section.text, sectionDesc.previewText, ); hasOne = true; } return false; }); if (hasMore) { setTimeout(() => makeOne(), 10); } else { editorSvc.previewCtxWithDiffs = editorSvc.previewCtx; editorSvc.$emit('previewCtxWithDiffs', editorSvc.previewCtxWithDiffs); } }; makeOne(); } }, /** * Save editor selection/scroll state into the store. */ saveContentState: allowDebounce(() => { const scrollPosition = editorSvc.getScrollPosition() || store.getters['contentState/current'].scrollPosition; store.dispatch('contentState/patchCurrent', { selectionStart: editorSvc.clEditor.selectionMgr.selectionStart, selectionEnd: editorSvc.clEditor.selectionMgr.selectionEnd, scrollPosition, }); }, 100), /** * Report selection from the preview to the editor. */ saveSelection: allowDebounce(() => { const selection = window.getSelection(); let range = selection.rangeCount && selection.getRangeAt(0); if (range) { if ( /* eslint-disable no-bitwise */ !(editorSvc.previewElt.compareDocumentPosition(range.startContainer) & window.Node.DOCUMENT_POSITION_CONTAINED_BY) || !(editorSvc.previewElt.compareDocumentPosition(range.endContainer) & window.Node.DOCUMENT_POSITION_CONTAINED_BY) /* eslint-enable no-bitwise */ ) { range = null; } } if (editorSvc.previewSelectionRange !== range) { let previewSelectionStartOffset; let previewSelectionEndOffset; if (range) { const startRange = document.createRange(); startRange.setStart(editorSvc.previewElt, 0); startRange.setEnd(range.startContainer, range.startOffset); previewSelectionStartOffset = `${startRange}`.length; previewSelectionEndOffset = previewSelectionStartOffset + `${range}`.length; const editorStartOffset = editorSvc.getEditorOffset(previewSelectionStartOffset); const editorEndOffset = editorSvc.getEditorOffset(previewSelectionEndOffset); if (editorStartOffset != null && editorEndOffset != null) { editorSvc.clEditor.selectionMgr.setSelectionStartEnd( editorStartOffset, editorEndOffset, ); } } editorSvc.previewSelectionRange = range; editorSvc.$emit('previewSelectionRange', editorSvc.previewSelectionRange); } }, 50), /** * Returns the pandoc AST generated from the file tokens and the converter options */ getPandocAst() { return tokens && markdownItPandocRenderer(tokens, this.converter.options); }, /** * Pass the elements to the store and initialize the editor. */ init(editorElt, previewElt, tocElt) { this.editorElt = editorElt; this.previewElt = previewElt; this.tocElt = tocElt; this.createClEditor(editorElt); this.clEditor.on('contentChanged', (content, diffs, sectionList) => { this.parsingCtx = { ...this.parsingCtx, sectionList, }; }); this.clEditor.undoMgr.on('undoStateChange', () => { const canUndo = this.clEditor.undoMgr.canUndo(); if (canUndo !== store.state.layout.canUndo) { store.commit('layout/setCanUndo', canUndo); } const canRedo = this.clEditor.undoMgr.canRedo(); if (canRedo !== store.state.layout.canRedo) { store.commit('layout/setCanRedo', canRedo); } }); this.pagedownEditor = pagedown({ input: Object.create(this.clEditor), }); this.pagedownEditor.run(); this.pagedownEditor.hooks.set('insertLinkDialog', (callback) => { store.dispatch('modal/open', { type: 'link', callback, }); return true; }); this.pagedownEditor.hooks.set('insertImageDialog', (callback) => { store.dispatch('modal/open', { type: 'image', callback, }); return true; }); this.editorElt.parentNode.addEventListener('scroll', () => this.saveContentState(true)); this.previewElt.parentNode.addEventListener('scroll', () => this.saveContentState(true)); const refreshPreview = allowDebounce(() => { this.convert(); if (instantPreview) { this.refreshPreview(); this.measureSectionDimensions(false, true); } else { setTimeout(() => this.refreshPreview(), 10); } instantPreview = false; }, 25); let newSectionList; let newSelectionRange; const onEditorChanged = allowDebounce(() => { if (this.sectionList !== newSectionList) { this.sectionList = newSectionList; this.$emit('sectionList', this.sectionList); refreshPreview(!instantPreview); } if (this.selectionRange !== newSelectionRange) { this.selectionRange = newSelectionRange; this.$emit('selectionRange', this.selectionRange); } this.saveContentState(); }, 10); this.clEditor.selectionMgr.on('selectionChanged', (start, end, selectionRange) => { newSelectionRange = selectionRange; onEditorChanged(!instantPreview); }); /* ----------------------------- * Inline images */ const imgCache = Object.create(null); const hashImgElt = imgElt => `${imgElt.src}:${imgElt.width || -1}:${imgElt.height || -1}`; const addToImgCache = (imgElt) => { const hash = hashImgElt(imgElt); let entries = imgCache[hash]; if (!entries) { entries = []; imgCache[hash] = entries; } entries.push(imgElt); }; const getFromImgCache = (imgEltsToCache) => { const hash = hashImgElt(imgEltsToCache); const entries = imgCache[hash]; if (!entries) { return null; } let imgElt; return entries .some((entry) => { if (this.editorElt.contains(entry)) { return false; } imgElt = entry; return true; }) && imgElt; }; const triggerImgCacheGc = cledit.Utils.debounce(() => { Object.entries(imgCache).forEach(([src, entries]) => { // Filter entries that are not attached to the DOM const filteredEntries = entries.filter(imgElt => this.editorElt.contains(imgElt)); if (filteredEntries.length) { imgCache[src] = filteredEntries; } else { delete imgCache[src]; } }); }, 100); let imgEltsToCache = []; if (store.getters['data/computedSettings'].editor.inlineImages) { this.clEditor.highlighter.on('sectionHighlighted', (section) => { section.elt.getElementsByClassName('token img').cl_each((imgTokenElt) => { const srcElt = imgTokenElt.querySelector('.token.cl-src'); if (srcElt) { // Create an img element before the .img.token and wrap both elements // into a .token.img-wrapper const imgElt = document.createElement('img'); imgElt.style.display = 'none'; const uri = srcElt.textContent; if (!/^unsafe/.test(htmlSanitizer.sanitizeUri(uri, true))) { imgElt.onload = () => { imgElt.style.display = ''; }; imgElt.src = uri; // Take img size into account const sizeElt = imgTokenElt.querySelector('.token.cl-size'); if (sizeElt) { const match = sizeElt.textContent.match(/=(\d*)x(\d*)/); if (match[1]) { imgElt.width = parseInt(match[1], 10); } if (match[2]) { imgElt.height = parseInt(match[2], 10); } } imgEltsToCache.push(imgElt); } const imgTokenWrapper = document.createElement('span'); imgTokenWrapper.className = 'token img-wrapper'; imgTokenElt.parentNode.insertBefore(imgTokenWrapper, imgTokenElt); imgTokenWrapper.appendChild(imgElt); imgTokenWrapper.appendChild(imgTokenElt); } }); }); } this.clEditor.highlighter.on('highlighted', () => { imgEltsToCache.forEach((imgElt) => { const cachedImgElt = getFromImgCache(imgElt); if (cachedImgElt) { // Found a previously loaded image that has just been released imgElt.parentNode.replaceChild(cachedImgElt, imgElt); } else { addToImgCache(imgElt); } }); imgEltsToCache = []; // Eject released images from cache triggerImgCacheGc(); }); this.clEditor.on('contentChanged', (content, diffs, sectionList) => { newSectionList = sectionList; onEditorChanged(!instantPreview); }); // clEditorSvc.setPreviewElt(element[0].querySelector('.preview__inner-2')) // var previewElt = element[0].querySelector('.preview') // clEditorSvc.isPreviewTop = previewElt.scrollTop < 10 // previewElt.addEventListener('scroll', function () { // var isPreviewTop = previewElt.scrollTop < 10 // if (isPreviewTop !== clEditorSvc.isPreviewTop) { // clEditorSvc.isPreviewTop = isPreviewTop // scope.$apply() // } // }) // Watch file content changes let lastContentId = null; let lastProperties; store.watch( () => store.getters['content/currentChangeTrigger'], () => { const content = store.getters['content/current']; // Track ID changes let initClEditor = false; if (content.id !== lastContentId) { instantPreview = true; lastContentId = content.id; initClEditor = true; } // Track properties changes if (content.properties !== lastProperties) { lastProperties = content.properties; const options = extensionSvc.getOptions(store.getters['content/currentProperties']); if (utils.serializeObject(options) !== utils.serializeObject(this.options)) { this.options = options; this.initPrism(); this.initConverter(); initClEditor = true; } } if (initClEditor) { this.initClEditor(); } // Apply potential text and discussion changes this.applyContent(); }, { immediate: true, }, ); // Disable editor if hidden or if no content is loaded store.watch( () => store.getters['content/isCurrentEditable'], editable => this.clEditor.toggleEditable(!!editable), { immediate: true, }, ); store.watch( () => utils.serializeObject(store.getters['layout/styles']), () => this.measureSectionDimensions(false, true, true), ); this.initHighlighters(); this.$emit('inited'); }, }); export default editorSvc; ================================================ FILE: src/services/explorerSvc.js ================================================ import store from '../store'; import workspaceSvc from './workspaceSvc'; import badgeSvc from './badgeSvc'; export default { newItem(isFolder = false) { let parentId = store.getters['explorer/selectedNodeFolder'].item.id; if (parentId === 'trash' // Not allowed to create new items in the trash || (isFolder && parentId === 'temp') // Not allowed to create new folders in the temp folder ) { parentId = null; } store.dispatch('explorer/openNode', parentId); store.commit('explorer/setNewItem', { type: isFolder ? 'folder' : 'file', parentId, }); }, async deleteItem() { const selectedNode = store.getters['explorer/selectedNode']; if (selectedNode.isNil) { return; } if (selectedNode.isTrash || selectedNode.item.parentId === 'trash') { try { await store.dispatch('modal/open', 'trashDeletion'); } catch (e) { // Cancel } return; } // See if we have a confirmation dialog to show let moveToTrash = true; try { if (selectedNode.isTemp) { await store.dispatch('modal/open', 'tempFolderDeletion'); moveToTrash = false; } else if (selectedNode.item.parentId === 'temp') { await store.dispatch('modal/open', { type: 'tempFileDeletion', item: selectedNode.item, }); moveToTrash = false; } else if (selectedNode.isFolder) { await store.dispatch('modal/open', { type: 'folderDeletion', item: selectedNode.item, }); } } catch (e) { return; // cancel } const deleteFile = (id) => { if (moveToTrash) { workspaceSvc.setOrPatchItem({ id, parentId: 'trash', }); } else { workspaceSvc.deleteFile(id); } }; if (selectedNode === store.getters['explorer/selectedNode']) { const currentFileId = store.getters['file/current'].id; let doClose = selectedNode.item.id === currentFileId; if (selectedNode.isFolder) { const recursiveDelete = (folderNode) => { folderNode.folders.forEach(recursiveDelete); folderNode.files.forEach((fileNode) => { doClose = doClose || fileNode.item.id === currentFileId; deleteFile(fileNode.item.id); }); store.commit('folder/deleteItem', folderNode.item.id); }; recursiveDelete(selectedNode); badgeSvc.addBadge('removeFolder'); } else { deleteFile(selectedNode.item.id); badgeSvc.addBadge('removeFile'); } if (doClose) { // Close the current file by opening the last opened, not deleted one store.getters['data/lastOpenedIds'].some((id) => { const file = store.state.file.itemsById[id]; if (file.parentId === 'trash') { return false; } store.commit('file/setCurrentId', id); return true; }); } } }, }; ================================================ FILE: src/services/exportSvc.js ================================================ import FileSaver from 'file-saver'; import TemplateWorker from 'worker-loader!./templateWorker.js'; // eslint-disable-line import localDbSvc from './localDbSvc'; import markdownConversionSvc from './markdownConversionSvc'; import extensionSvc from './extensionSvc'; import utils from './utils'; import store from '../store'; import htmlSanitizer from '../libs/htmlSanitizer'; function groupHeadings(headings, level = 1) { const result = []; let currentItem; function pushCurrentItem() { if (currentItem) { if (currentItem.children.length > 0) { currentItem.children = groupHeadings(currentItem.children, level + 1); } result.push(currentItem); } } headings.forEach((heading) => { if (heading.level !== level) { currentItem = currentItem || { children: [], }; currentItem.children.push(heading); } else { pushCurrentItem(); currentItem = heading; } }); pushCurrentItem(); return result; } const containerElt = document.createElement('div'); containerElt.className = 'hidden-rendering-container'; document.body.appendChild(containerElt); export default { /** * Apply the template to the file content */ async applyTemplate(fileId, template = { value: '{{{files.0.content.text}}}', helpers: '', }, pdf = false) { const file = store.state.file.itemsById[fileId]; const content = await localDbSvc.loadItem(`${fileId}/content`); const properties = utils.computeProperties(content.properties); const options = extensionSvc.getOptions(properties); const converter = markdownConversionSvc.createConverter(options, true); const parsingCtx = markdownConversionSvc.parseSections(converter, content.text); const conversionCtx = markdownConversionSvc.convert(parsingCtx); const html = conversionCtx.htmlSectionList.map(htmlSanitizer.sanitizeHtml).join(''); containerElt.innerHTML = html; extensionSvc.sectionPreview(containerElt, options); // Unwrap tables containerElt.querySelectorAll('.table-wrapper').cl_each((wrapperElt) => { while (wrapperElt.firstChild) { wrapperElt.parentNode.insertBefore(wrapperElt.firstChild, wrapperElt.nextSibling); } wrapperElt.parentNode.removeChild(wrapperElt); }); // Make TOC const headings = containerElt.querySelectorAll('h1,h2,h3,h4,h5,h6').cl_map(headingElt => ({ title: headingElt.textContent, anchor: headingElt.id, level: parseInt(headingElt.tagName.slice(1), 10), children: [], })); const toc = groupHeadings(headings); const view = { pdf, files: [{ name: file.name, content: { text: content.text, properties, yamlProperties: content.properties, html: containerElt.innerHTML, toc, }, }], }; containerElt.innerHTML = ''; // Run template conversion in a Worker to prevent attacks from helpers const worker = new TemplateWorker(); return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { worker.terminate(); reject(new Error('Template generation timeout.')); }, 10000); worker.addEventListener('message', (e) => { clearTimeout(timeoutId); worker.terminate(); // e.data can contain unsafe data if helpers attempts to call postMessage const [err, result] = e.data; if (err) { reject(new Error(`${err}`)); } else { resolve(`${result}`); } }); worker.postMessage([template.value, view, template.helpers]); }); }, /** * Export a file to disk. */ async exportToDisk(fileId, type, template) { const file = store.state.file.itemsById[fileId]; const html = await this.applyTemplate(fileId, template); const blob = new Blob([html], { type: 'text/plain;charset=utf-8', }); FileSaver.saveAs(blob, `${file.name}.${type}`); }, }; ================================================ FILE: src/services/extensionSvc.js ================================================ const getOptionsListeners = []; const initConverterListeners = []; const sectionPreviewListeners = []; export default { onGetOptions(listener) { getOptionsListeners.push(listener); }, onInitConverter(priority, listener) { initConverterListeners[priority] = listener; }, onSectionPreview(listener) { sectionPreviewListeners.push(listener); }, getOptions(properties, isCurrentFile) { return getOptionsListeners.reduce((options, listener) => { listener(options, properties, isCurrentFile); return options; }, {}); }, initConverter(markdown, options) { // Use forEach as it's a sparsed array initConverterListeners.forEach((listener) => { listener(markdown, options); }); }, sectionPreview(elt, options, isEditor) { sectionPreviewListeners.forEach((listener) => { listener(elt, options, isEditor); }); }, }; ================================================ FILE: src/services/gitWorkspaceSvc.js ================================================ import store from '../store'; import utils from '../services/utils'; const endsWith = (str, suffix) => str.slice(-suffix.length) === suffix; export default { shaByPath: Object.create(null), makeChanges(tree) { const workspacePath = store.getters['workspace/currentWorkspace'].path || ''; // Store all blobs sha this.shaByPath = Object.create(null); // Store interesting paths const treeFolderMap = Object.create(null); const treeFileMap = Object.create(null); const treeDataMap = Object.create(null); const treeSyncLocationMap = Object.create(null); const treePublishLocationMap = Object.create(null); tree.filter(({ type, path }) => type === 'blob' && path.indexOf(workspacePath) === 0) .forEach((blobEntry) => { // Make path relative const path = blobEntry.path.slice(workspacePath.length); // Collect blob sha this.shaByPath[path] = blobEntry.sha; if (path.indexOf('.stackedit-data/') === 0) { treeDataMap[path] = true; } else { // Collect parents path let parentPath = ''; path.split('/').slice(0, -1).forEach((folderName) => { const folderPath = `${parentPath}${folderName}/`; treeFolderMap[folderPath] = parentPath; parentPath = folderPath; }); // Collect file path if (endsWith(path, '.md')) { treeFileMap[path] = parentPath; } else if (endsWith(path, '.sync')) { treeSyncLocationMap[path] = true; } else if (endsWith(path, '.publish')) { treePublishLocationMap[path] = true; } } }); // Collect changes const changes = []; const idsByPath = {}; const syncDataByPath = store.getters['data/syncDataById']; const { itemIdsByGitPath } = store.getters; const getIdFromPath = (path, isFile) => { let itemId = idsByPath[path]; if (!itemId) { const existingItemId = itemIdsByGitPath[path]; if (existingItemId // Reuse a file ID only if it has already been synced && (!isFile || syncDataByPath[path] // Content may have already been synced || syncDataByPath[`/${path}`]) ) { itemId = existingItemId; } else { // Otherwise, make a new ID for a new item itemId = utils.uid(); } // If it's a file path, add the content path as well if (isFile) { idsByPath[`/${path}`] = `${itemId}/content`; } idsByPath[path] = itemId; } return itemId; }; // Folder creations/updates // Assume map entries are sorted from top to bottom Object.entries(treeFolderMap).forEach(([path, parentPath]) => { if (path === '.stackedit-trash/') { idsByPath[path] = 'trash'; } else { const item = utils.addItemHash({ id: getIdFromPath(path), type: 'folder', name: path.slice(parentPath.length, -1), parentId: idsByPath[parentPath] || null, }); const folderSyncData = syncDataByPath[path]; if (!folderSyncData || folderSyncData.hash !== item.hash) { changes.push({ syncDataId: path, item, syncData: { id: path, type: item.type, hash: item.hash, }, }); } } }); // File/content creations/updates Object.entries(treeFileMap).forEach(([path, parentPath]) => { const fileId = getIdFromPath(path, true); const contentPath = `/${path}`; const contentId = idsByPath[contentPath]; // File creations/updates const item = utils.addItemHash({ id: fileId, type: 'file', name: path.slice(parentPath.length, -'.md'.length), parentId: idsByPath[parentPath] || null, }); const fileSyncData = syncDataByPath[path]; if (!fileSyncData || fileSyncData.hash !== item.hash) { changes.push({ syncDataId: path, item, syncData: { id: path, type: item.type, hash: item.hash, }, }); } // Content creations/updates const contentSyncData = syncDataByPath[contentPath]; if (!contentSyncData || contentSyncData.sha !== this.shaByPath[path]) { const type = 'content'; // Use `/` as a prefix to get a unique syncData id changes.push({ syncDataId: contentPath, item: { id: contentId, type, // Need a truthy value to force downloading the content hash: 1, }, syncData: { id: contentPath, type, // Need a truthy value to force downloading the content hash: 1, }, }); } }); // Data creations/updates const syncDataByItemId = store.getters['data/syncDataByItemId']; Object.keys(treeDataMap).forEach((path) => { // Only template data are stored const [, id] = path.match(/^\.stackedit-data\/(templates)\.json$/) || []; if (id) { idsByPath[path] = id; const syncData = syncDataByItemId[id]; if (!syncData || syncData.sha !== this.shaByPath[path]) { const type = 'data'; changes.push({ syncDataId: path, item: { id, type, // Need a truthy value to force saving sync data hash: 1, }, syncData: { id: path, type, // Need a truthy value to force downloading the content hash: 1, }, }); } } }); // Location creations/updates [{ type: 'syncLocation', map: treeSyncLocationMap, pathMatcher: /^([\s\S]+)\.([\w-]+)\.sync$/, }, { type: 'publishLocation', map: treePublishLocationMap, pathMatcher: /^([\s\S]+)\.([\w-]+)\.publish$/, }] .forEach(({ type, map, pathMatcher }) => Object.keys(map).forEach((path) => { const [, filePath, data] = path.match(pathMatcher) || []; if (filePath) { // If there is a corresponding md file in the tree const fileId = idsByPath[`${filePath}.md`]; if (fileId) { // Reuse existing ID or create a new one const id = itemIdsByGitPath[path] || utils.uid(); idsByPath[path] = id; const item = utils.addItemHash({ ...JSON.parse(utils.decodeBase64(data)), id, type, fileId, }); const locationSyncData = syncDataByPath[path]; if (!locationSyncData || locationSyncData.hash !== item.hash) { changes.push({ syncDataId: path, item, syncData: { id: path, type: item.type, hash: item.hash, }, }); } } } })); // Deletions Object.keys(syncDataByPath).forEach((path) => { if (!idsByPath[path]) { changes.push({ syncDataId: path }); } }); return changes; }, }; ================================================ FILE: src/services/localDbSvc.js ================================================ import utils from './utils'; import store from '../store'; import welcomeFile from '../data/welcomeFile.md'; import workspaceSvc from './workspaceSvc'; import constants from '../data/constants'; const deleteMarkerMaxAge = 1000; const dbVersion = 1; const dbStoreName = 'objects'; const { silent } = utils.queryParams; const resetApp = localStorage.getItem('resetStackEdit'); if (resetApp) { localStorage.removeItem('resetStackEdit'); } class Connection { constructor(workspaceId = store.getters['workspace/currentWorkspace'].id) { this.getTxCbs = []; // Make the DB name this.dbName = utils.getDbName(workspaceId); // Init connection const request = indexedDB.open(this.dbName, dbVersion); request.onerror = () => { throw new Error("Can't connect to IndexedDB."); }; request.onsuccess = (event) => { this.db = event.target.result; this.db.onversionchange = () => window.location.reload(); this.getTxCbs.forEach(({ onTx, onError }) => this.createTx(onTx, onError)); this.getTxCbs = null; }; request.onupgradeneeded = (event) => { const eventDb = event.target.result; const oldVersion = event.oldVersion || 0; // We don't use 'break' in this switch statement, // the fall-through behavior is what we want. /* eslint-disable no-fallthrough */ switch (oldVersion) { case 0: { // Create store const dbStore = eventDb.createObjectStore(dbStoreName, { keyPath: 'id', }); dbStore.createIndex('tx', 'tx', { unique: false, }); } default: } /* eslint-enable no-fallthrough */ }; } /** * Create a transaction asynchronously. */ createTx(onTx, onError) { // If DB is not ready, keep callbacks for later if (!this.db) { return this.getTxCbs.push({ onTx, onError }); } // Open transaction in read/write will prevent conflict with other tabs const tx = this.db.transaction(this.db.objectStoreNames, 'readwrite'); tx.onerror = onError; return onTx(tx); } } const contentTypes = { content: true, contentState: true, syncedContent: true, }; const hashMap = {}; constants.types.forEach((type) => { hashMap[type] = Object.create(null); }); const lsHashMap = Object.create(null); const localDbSvc = { lastTx: 0, hashMap, connection: null, /** * Sync data items stored in the localStorage. */ syncLocalStorage() { constants.localStorageDataIds.forEach((id) => { const key = `data/${id}`; // Skip reloading the layoutSettings if (id !== 'layoutSettings' || !lsHashMap[id]) { try { // Try to parse the item from the localStorage const storedItem = JSON.parse(localStorage.getItem(key)); if (storedItem.hash && lsHashMap[id] !== storedItem.hash) { // Item has changed, replace it in the store store.commit('data/setItem', storedItem); lsHashMap[id] = storedItem.hash; } } catch (e) { // Ignore parsing issue } } // Write item if different from stored one const item = store.state.data.lsItemsById[id]; if (item && item.hash !== lsHashMap[id]) { localStorage.setItem(key, JSON.stringify(item)); lsHashMap[id] = item.hash; } }); }, /** * Return a promise that will be resolved once the synchronization between the store and the * localDb will be finished. Effectively, open a transaction, then read and apply all changes * from the DB since the previous transaction, then write all the changes from the store. */ async sync() { return new Promise((resolve, reject) => { // Create the DB transaction this.connection.createTx((tx) => { const { lastTx } = this; // Look for DB changes and apply them to the store this.readAll(tx, (storeItemMap) => { // Sanitize the workspace if changes have been applied if (lastTx !== this.lastTx) { workspaceSvc.sanitizeWorkspace(); } // Persist all the store changes into the DB this.writeAll(storeItemMap, tx); // Sync the localStorage this.syncLocalStorage(); // Done resolve(); }); }, () => reject(new Error('Local DB access error.'))); }); }, /** * Read and apply all changes from the DB since previous transaction. */ readAll(tx, cb) { let { lastTx } = this; const dbStore = tx.objectStore(dbStoreName); const index = dbStore.index('tx'); const range = IDBKeyRange.lowerBound(this.lastTx, true); const changes = []; index.openCursor(range).onsuccess = (event) => { const cursor = event.target.result; if (cursor) { const item = cursor.value; if (item.tx > lastTx) { lastTx = item.tx; if (this.lastTx && item.tx - this.lastTx > deleteMarkerMaxAge) { // We may have missed some delete markers window.location.reload(); return; } } // Collect change changes.push(item); cursor.continue(); return; } // Read the collected changes const storeItemMap = { ...store.getters.allItemsById }; changes.forEach((item) => { this.readDbItem(item, storeItemMap); // If item is an old delete marker, remove it from the DB if (!item.hash && lastTx - item.tx > deleteMarkerMaxAge) { dbStore.delete(item.id); } }); this.lastTx = lastTx; cb(storeItemMap); }; }, /** * Write all changes from the store since previous transaction. */ writeAll(storeItemMap, tx) { if (silent) { // Skip writing to DB in silent mode return; } const dbStore = tx.objectStore(dbStoreName); const incrementedTx = this.lastTx + 1; // Remove deleted store items Object.keys(this.hashMap).forEach((type) => { // Remove this type only if file is deleted let checker = cb => id => !storeItemMap[id] && cb(id); if (contentTypes[type]) { // For content types, remove item only if file is deleted checker = cb => (id) => { if (!storeItemMap[id]) { const [fileId] = id.split('/'); if (!store.state.file.itemsById[fileId]) { cb(id); } } }; } Object.keys(this.hashMap[type]).forEach(checker((id) => { // Put a delete marker to notify other tabs dbStore.put({ id, type, tx: incrementedTx, }); delete this.hashMap[type][id]; this.lastTx = incrementedTx; })); }); // Put changes Object.entries(storeItemMap).forEach(([, storeItem]) => { // Store object has changed if (this.hashMap[storeItem.type][storeItem.id] !== storeItem.hash) { const item = { ...storeItem, tx: incrementedTx, }; dbStore.put(item); this.hashMap[item.type][item.id] = item.hash; this.lastTx = incrementedTx; } }); }, /** * Read and apply one DB change. */ readDbItem(dbItem, storeItemMap) { const storeItem = storeItemMap[dbItem.id]; if (!dbItem.hash) { // DB item is a delete marker delete this.hashMap[dbItem.type][dbItem.id]; if (storeItem) { // Remove item from the store store.commit(`${storeItem.type}/deleteItem`, storeItem.id); delete storeItemMap[storeItem.id]; } } else if (this.hashMap[dbItem.type][dbItem.id] !== dbItem.hash) { // DB item is different from the corresponding store item this.hashMap[dbItem.type][dbItem.id] = dbItem.hash; // Update content only if it exists in the store if (storeItem || !contentTypes[dbItem.type]) { // Put item in the store dbItem.tx = undefined; store.commit(`${dbItem.type}/setItem`, dbItem); storeItemMap[dbItem.id] = dbItem; } } }, /** * Retrieve an item from the DB and put it in the store. */ async loadItem(id) { // Check if item is in the store const itemInStore = store.getters.allItemsById[id]; if (itemInStore) { // Use deepCopy to freeze item return Promise.resolve(itemInStore); } return new Promise((resolve, reject) => { // Get the item from DB const onError = () => reject(new Error('Data not available.')); this.connection.createTx((tx) => { const dbStore = tx.objectStore(dbStoreName); const request = dbStore.get(id); request.onsuccess = () => { const dbItem = request.result; if (!dbItem || !dbItem.hash) { onError(); } else { this.hashMap[dbItem.type][dbItem.id] = dbItem.hash; // Put item in the store dbItem.tx = undefined; store.commit(`${dbItem.type}/setItem`, dbItem); resolve(dbItem); } }; }, () => onError()); }); }, /** * Unload from the store contents that haven't been opened recently */ async unloadContents() { await this.sync(); // Keep only last opened files in memory const lastOpenedFileIdSet = new Set(store.getters['data/lastOpenedIds']); Object.keys(contentTypes).forEach((type) => { store.getters[`${type}/items`].forEach((item) => { const [fileId] = item.id.split('/'); if (!lastOpenedFileIdSet.has(fileId)) { // Remove item from the store store.commit(`${type}/deleteItem`, item.id); } }); }); }, /** * Create the connection and start syncing. */ async init() { // Reset the app if the reset flag was passed if (resetApp) { await Promise.all(Object.keys(store.getters['workspace/workspacesById']) .map(workspaceId => workspaceSvc.removeWorkspace(workspaceId))); constants.localStorageDataIds.forEach((id) => { // Clean data stored in localStorage localStorage.removeItem(`data/${id}`); }); throw new Error('RELOAD'); } // Create the connection this.connection = new Connection(); // Load the DB await localDbSvc.sync(); // Watch workspace deletions and persist them as soon as possible // to make the changes available to reloading workspace tabs. store.watch( () => store.getters['data/workspaces'], () => this.syncLocalStorage(), ); // Save welcome file content hash if not done already const hash = utils.hash(welcomeFile); const { welcomeFileHashes } = store.getters['data/localSettings']; if (!welcomeFileHashes[hash]) { store.dispatch('data/patchLocalSettings', { welcomeFileHashes: { ...welcomeFileHashes, [hash]: 1, }, }); } // If app was last opened 7 days ago and synchronization is off if (!store.getters['workspace/syncToken'] && (store.state.workspace.lastFocus + constants.cleanTrashAfter < Date.now()) ) { // Clean files store.getters['file/items'] .filter(file => file.parentId === 'trash') // If file is in the trash .forEach(file => workspaceSvc.deleteFile(file.id)); } // Sync local DB periodically utils.setInterval(() => localDbSvc.sync(), 1000); // watch current file changing store.watch( () => store.getters['file/current'].id, async () => { // See if currentFile is real, ie it has an ID const currentFile = store.getters['file/current']; // If current file has no ID, get the most recent file if (!currentFile.id) { const recentFile = store.getters['file/lastOpened']; // Set it as the current file if (recentFile.id) { store.commit('file/setCurrentId', recentFile.id); } else { // If still no ID, create a new file const newFile = await workspaceSvc.createFile({ name: 'Welcome file', text: welcomeFile, }, true); // Set it as the current file store.commit('file/setCurrentId', newFile.id); } } else { try { // Load contentState from DB await localDbSvc.loadContentState(currentFile.id); // Load syncedContent from DB await localDbSvc.loadSyncedContent(currentFile.id); // Load content from DB try { await localDbSvc.loadItem(`${currentFile.id}/content`); } catch (err) { // Failure (content is not available), go back to previous file const lastOpenedFile = store.getters['file/lastOpened']; store.commit('file/setCurrentId', lastOpenedFile.id); throw err; } // Set last opened file store.dispatch('data/setLastOpenedId', currentFile.id); // Cancel new discussion and open the gutter if file contains discussions store.commit( 'discussion/setCurrentDiscussionId', store.getters['discussion/nextDiscussionId'], ); } catch (err) { console.error(err); // eslint-disable-line no-console store.dispatch('notification/error', err); } } }, { immediate: true }, ); }, getWorkspaceItems(workspaceId, onItem, onFinish = () => {}) { const connection = new Connection(workspaceId); connection.createTx((tx) => { const dbStore = tx.objectStore(dbStoreName); const index = dbStore.index('tx'); index.openCursor().onsuccess = (event) => { const cursor = event.target.result; if (cursor) { onItem(cursor.value); cursor.continue(); } else { connection.db.close(); onFinish(); } }; }); // Return a cancel function return () => connection.db.close(); }, }; const loader = type => fileId => localDbSvc.loadItem(`${fileId}/${type}`) // Item does not exist, create it .catch(() => store.commit(`${type}/setItem`, { id: `${fileId}/${type}`, })); localDbSvc.loadSyncedContent = loader('syncedContent'); localDbSvc.loadContentState = loader('contentState'); export default localDbSvc; ================================================ FILE: src/services/markdownConversionSvc.js ================================================ import DiffMatchPatch from 'diff-match-patch'; import Prism from 'prismjs'; import MarkdownIt from 'markdown-it'; import markdownGrammarSvc from './markdownGrammarSvc'; import extensionSvc from './extensionSvc'; import utils from './utils'; const htmlSectionMarker = '\uF111\uF222\uF333\uF444'; const diffMatchPatch = new DiffMatchPatch(); // Create aliases for syntax highlighting const languageAliases = ({ js: 'javascript', json: 'javascript', html: 'markup', svg: 'markup', xml: 'markup', py: 'python', rb: 'ruby', yml: 'yaml', ps1: 'powershell', psm1: 'powershell', }); Object.entries(languageAliases).forEach(([alias, language]) => { Prism.languages[alias] = Prism.languages[language]; }); // Add programming language parsing capability to markdown fences const insideFences = {}; Object.entries(Prism.languages).forEach(([name, language]) => { if (Prism.util.type(language) === 'Object') { insideFences[`language-${name}`] = { pattern: new RegExp(`(\`\`\`|~~~)${name}\\W[\\s\\S]*`), inside: { 'cl cl-pre': /(```|~~~).*/, rest: language, }, }; } }); // Disable spell checking in specific tokens const noSpellcheckTokens = Object.create(null); [ 'code', 'pre', 'pre gfm', 'math block', 'math inline', 'math expr block', 'math expr inline', 'latex block', ] .forEach((key) => { noSpellcheckTokens[key] = true; }); Prism.hooks.add('wrap', (env) => { if (noSpellcheckTokens[env.type]) { env.attributes.spellcheck = 'false'; } }); function createFlagMap(arr) { return arr.reduce((map, type) => ({ ...map, [type]: true }), {}); } const startSectionBlockTypeMap = createFlagMap([ 'paragraph_open', 'blockquote_open', 'heading_open', 'code', 'fence', 'table_open', 'html_block', 'bullet_list_open', 'ordered_list_open', 'hr', 'dl_open', ]); const listBlockTypeMap = createFlagMap([ 'bullet_list_open', 'ordered_list_open', ]); const blockquoteBlockTypeMap = createFlagMap([ 'blockquote_open', ]); const tableBlockTypeMap = createFlagMap([ 'table_open', ]); const deflistBlockTypeMap = createFlagMap([ 'dl_open', ]); function hashArray(arr, valueHash, valueArray) { const hash = []; arr.forEach((str) => { let strHash = valueHash[str]; if (strHash === undefined) { strHash = valueArray.length; valueArray.push(str); valueHash[str] = strHash; } hash.push(strHash); }); return String.fromCharCode.apply(null, hash); } export default { defaultOptions: null, defaultConverter: null, defaultPrismGrammars: null, init() { const defaultProperties = { extensions: utils.computedPresets.default }; // Default options for the markdown converter and the grammar this.defaultOptions = { ...extensionSvc.getOptions(defaultProperties), insideFences, }; this.defaultConverter = this.createConverter(this.defaultOptions); this.defaultPrismGrammars = markdownGrammarSvc.makeGrammars(this.defaultOptions); }, /** * Creates a converter and init it with extensions. * @returns {Object} A converter. */ createConverter(options) { // Let the listeners add the rules const converter = new MarkdownIt('zero'); converter.core.ruler.enable([], true); converter.block.ruler.enable([], true); converter.inline.ruler.enable([], true); extensionSvc.initConverter(converter, options); Object.keys(startSectionBlockTypeMap).forEach((type) => { const rule = converter.renderer.rules[type] || converter.renderer.renderToken; converter.renderer.rules[type] = (tokens, idx, opts, env, self) => { if (tokens[idx].sectionDelimiter) { // Add section delimiter return htmlSectionMarker + rule.call(converter.renderer, tokens, idx, opts, env, self); } return rule.call(converter.renderer, tokens, idx, opts, env, self); }; }); return converter; }, /** * Parse markdown sections by passing the 2 first block rules of the markdown-it converter. * @param {Object} converter The markdown-it converter. * @param {String} text The text to be parsed. * @returns {Object} A parsing context to be passed to `convert`. */ parseSections(converter, text) { const markdownState = new converter.core.State(text, converter, {}); const markdownCoreRules = converter.core.ruler.getRules(''); markdownCoreRules[0](markdownState); // Pass the normalize rule markdownCoreRules[1](markdownState); // Pass the block rule const lines = text.split('\n'); if (!lines[lines.length - 1]) { // In cledit, last char is always '\n'. // Remove it as one will be added by addSection lines.pop(); } const parsingCtx = { text, sections: [], converter, markdownState, markdownCoreRules, }; let data = 'main'; let i = 0; function addSection(maxLine) { const section = { text: '', data, }; for (; i < maxLine; i += 1) { section.text += `${lines[i]}\n`; } if (section) { parsingCtx.sections.push(section); } } markdownState.tokens.forEach((token, index) => { // index === 0 means there are empty lines at the begining of the file if (token.level === 0 && startSectionBlockTypeMap[token.type] === true) { if (index > 0) { token.sectionDelimiter = true; addSection(token.map[0]); } if (listBlockTypeMap[token.type] === true) { data = 'list'; } else if (blockquoteBlockTypeMap[token.type] === true) { data = 'blockquote'; } else if (tableBlockTypeMap[token.type] === true) { data = 'table'; } else if (deflistBlockTypeMap[token.type] === true) { data = 'deflist'; } else { data = 'main'; } } }); addSection(lines.length); return parsingCtx; }, /** * Convert markdown sections previously parsed with `parseSections`. * @param {Object} parsingCtx The parsing context returned by `parseSections`. * @param {Object} previousConversionCtx The conversion context returned by a previous call * to `convert`, in order to calculate the `htmlSectionDiff` of the returned conversion context. * @returns {Object} A conversion context. */ convert(parsingCtx, previousConversionCtx) { // This function can be called twice without editor modification // so prevent from converting it again. if (!parsingCtx.markdownState.isConverted) { // Skip 2 first rules previously passed in parseSections parsingCtx.markdownCoreRules.slice(2).forEach(rule => rule(parsingCtx.markdownState)); parsingCtx.markdownState.isConverted = true; } const { tokens } = parsingCtx.markdownState; const html = parsingCtx.converter.renderer.render( tokens, parsingCtx.converter.options, parsingCtx.markdownState.env, ); const htmlSectionList = html.split(htmlSectionMarker); if (htmlSectionList[0] === '') { htmlSectionList.shift(); } const valueHash = Object.create(null); const valueArray = []; const newSectionHash = hashArray(htmlSectionList, valueHash, valueArray); let htmlSectionDiff; if (previousConversionCtx) { const oldSectionHash = hashArray( previousConversionCtx.htmlSectionList, valueHash, valueArray, ); htmlSectionDiff = diffMatchPatch.diff_main(oldSectionHash, newSectionHash, false); } else { htmlSectionDiff = [ [1, newSectionHash], ]; } return { text: parsingCtx.text, sectionList: parsingCtx.sectionList, htmlSectionList, htmlSectionDiff, }; }, /** * Helper to highlight arbitrary markdown * @param {Object} markdown The markdown content to highlight. * @param {Object} converter An optional converter. * @param {Object} grammars Optional grammars. * @returns {Object} The highlighted markdown in HTML format. */ highlight(markdown, converter = this.defaultConverter, grammars = this.defaultPrismGrammars) { const parsingCtx = this.parseSections(converter, markdown); return parsingCtx.sections .map(section => Prism.highlight(section.text, grammars[section.data])).join(''); }, }; ================================================ FILE: src/services/markdownGrammarSvc.js ================================================ const charInsideUrl = '(&|[-A-Z0-9+@#/%?=~_|[\\]()!:,.;])'; const charEndingUrl = '(&|[-A-Z0-9+@#/%=~_|[\\])])'; const urlPattern = new RegExp(`(https?|ftp)(://${charInsideUrl}*${charEndingUrl})(?=$|\\W)`, 'gi'); const emailPattern = /(?:mailto:)?([-.\w]+@[-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+)/gi; const markup = { comment: //g, tag: { pattern: /<\/?[\w:-]+\s*(?:\s+[\w:-]+(?:=(?:("|')(\\?[\w\W])*?\1|[^\s'">=]+))?\s*)*\/?>/gi, inside: { tag: { pattern: /^<\/?[\w:-]+/i, inside: { punctuation: /^<\/?/, namespace: /^[\w-]+?:/, }, }, 'attr-value': { pattern: /=(?:('|")[\w\W]*?(\1)|[^\s>]+)/gi, inside: { punctuation: /=|>|"/g, }, }, punctuation: /\/?>/g, 'attr-name': { pattern: /[\w:-]+/g, inside: { namespace: /^[\w-]+?:/, }, }, }, }, entity: /&#?[\da-z]{1,8};/gi, }; const latex = { // A tex command e.g. \foo keyword: /\\(?:[^a-zA-Z]|[a-zA-Z]+)/g, // Curly and square braces lparen: /[[({]/g, // Curly and square braces rparen: /[\])}]/g, // A comment. Tex comments start with % and go to // the end of the line comment: /%.*/g, }; export default { makeGrammars(options) { const grammars = { main: {}, list: {}, blockquote: {}, table: {}, deflist: {}, }; grammars.deflist.deflist = { pattern: new RegExp( [ '^ {0,3}\\S.*\\n', // Description line '(?:[ \\t]*\\n)?', // Optional empty line '(?:', '[ \\t]*:[ \\t].*\\n', // Colon line '(?:', '(?:', '.*\\S.*\\n', // Non-empty line '|', '[ \\t]*\\n(?! ?\\S)', // Or empty line not followed by unindented line ')', ')*', '(?:[ \\t]*\\n)*', // Empty lines ')+', ].join(''), 'm', ), inside: { term: /^.+/, cl: /^[ \t]*:[ \t]/gm, }, }; const insideFences = options.insideFences || {}; insideFences['cl cl-pre'] = /```|~~~/; if (options.fence) { grammars.main['pre gfm'] = { pattern: /^(```|~~~)[\s\S]*?\n\1 *$/gm, inside: insideFences, }; grammars.list['pre gfm'] = { pattern: /^(?: {4}|\t)(```|~~~)[\s\S]*?\n(?: {4}|\t)\1\s*$/gm, inside: insideFences, }; grammars.deflist.deflist.inside['pre gfm'] = grammars.list['pre gfm']; } grammars.main['h1 alt'] = { pattern: /^.+\n=+[ \t]*$/gm, inside: { 'cl cl-hash': /=+[ \t]*$/, }, }; grammars.main['h2 alt'] = { pattern: /^.+\n-+[ \t]*$/gm, inside: { 'cl cl-hash': /-+[ \t]*$/, }, }; for (let i = 6; i >= 1; i -= 1) { grammars.main[`h${i}`] = { pattern: new RegExp(`^#{${i}}[ \t].+$`, 'gm'), inside: { 'cl cl-hash': new RegExp(`^#{${i}}`), }, }; } const list = /^[ \t]*([*+-]|\d+\.)[ \t]/gm; const blockquote = { pattern: /^\s*>.*(?:\n[ \t]*\S.*)*/gm, inside: { 'cl cl-gt': /^\s*>/gm, 'cl cl-li': list, }, }; grammars.list.blockquote = blockquote; grammars.blockquote.blockquote = blockquote; grammars.deflist.deflist.inside.blockquote = blockquote; grammars.list['cl cl-li'] = list; grammars.blockquote['cl cl-li'] = list; grammars.deflist.deflist.inside['cl cl-li'] = list; grammars.table.table = { pattern: new RegExp( [ '^\\s*\\S.*[|].*\\n', // Header Row '[-| :]+\\n', // Separator '(?:.*[|].*\\n?)*', // Table rows '$', ].join(''), 'gm', ), inside: { 'cl cl-title-separator': /^[-| :]+$/gm, 'cl cl-pipe': /[|]/gm, }, }; grammars.main.hr = { pattern: /^ {0,3}([*\-_] *){3,}$/gm, }; if (options.tasklist) { grammars.list.task = { pattern: /^\[[ xX]\] /, inside: { cl: /[[\]]/, strong: /[xX]/, }, }; } const defs = {}; if (options.footnote) { defs.fndef = { pattern: /^ {0,3}\[\^.*?\]:.*$/gm, inside: { 'ref-id': { pattern: /^ {0,3}\[\^.*?\]/, inside: { cl: /(\[\^|\])/, }, }, }, }; } if (options.abbr) { defs.abbrdef = { pattern: /^ {0,3}\*\[.*?\]:.*$/gm, inside: { 'abbr-id': { pattern: /^ {0,3}\*\[.*?\]/, inside: { cl: /(\*\[|\])/, }, }, }, }; } defs.linkdef = { pattern: /^ {0,3}\[.*?\]:.*$/gm, inside: { 'link-id': { pattern: /^ {0,3}\[.*?\]/, inside: { cl: /[[\]]/, }, }, url: urlPattern, }, }; Object.entries(defs).forEach(([name, def]) => { grammars.main[name] = def; grammars.list[name] = def; grammars.blockquote[name] = def; grammars.table[name] = def; grammars.deflist[name] = def; }); grammars.main.pre = { pattern: /^\s*\n(?: {4}|\t).*\S.*\n((?: {4}|\t).*\n)*/gm, }; const rest = {}; rest.code = { pattern: /(`+)[\s\S]*?\1/g, inside: { 'cl cl-code': /`/, }, }; if (options.math) { rest['math block'] = { pattern: /\\\\\[[\s\S]*?\\\\\]/g, inside: { 'cl cl-bracket-start': /^\\\\\[/, 'cl cl-bracket-end': /\\\\\]$/, rest: latex, }, }; rest['math inline'] = { pattern: /\\\\\([\s\S]*?\\\\\)/g, inside: { 'cl cl-bracket-start': /^\\\\\(/, 'cl cl-bracket-end': /\\\\\)$/, rest: latex, }, }; rest['math expr block'] = { pattern: /(\$\$)[\s\S]*?\1/g, inside: { 'cl cl-bracket-start': /^\$\$/, 'cl cl-bracket-end': /\$\$$/, rest: latex, }, }; rest['math expr inline'] = { pattern: /\$(?!\s)[\s\S]*?\S\$(?!\d)/g, inside: { 'cl cl-bracket-start': /^\$/, 'cl cl-bracket-end': /\$$/, rest: latex, }, }; } if (options.footnote) { rest.inlinefn = { pattern: /\^\[.+?\]/g, inside: { cl: /(\^\[|\])/, }, }; rest.fn = { pattern: /\[\^.+?\]/g, inside: { cl: /(\[\^|\])/, }, }; } rest.img = { pattern: /!\[.*?\]\(.+?\)/g, inside: { 'cl cl-title': /['‘][^'’]*['’]|["“][^"”]*["”](?=\)$)/, 'cl cl-src': { pattern: /(\]\()[^('" \t]+(?=[)'" \t])/, lookbehind: true, }, }, }; if (options.imgsize) { rest.img.inside['cl cl-size'] = /=\d*x\d*/; } rest.link = { pattern: /\[.*?\]\(.+?\)/gm, inside: { 'cl cl-underlined-text': { pattern: /(\[)[^\]]*/, lookbehind: true, }, 'cl cl-title': /['‘][^'’]*['’]|["“][^"”]*["”](?=\)$)/, }, }; rest.imgref = { pattern: /!\[.*?\][ \t]*\[.*?\]/g, }; rest.linkref = { pattern: /\[.*?\][ \t]*\[.*?\]/g, inside: { 'cl cl-underlined-text': { pattern: /^(\[)[^\]]*(?=\][ \t]*\[)/, lookbehind: true, }, }, }; rest.comment = markup.comment; rest.tag = markup.tag; rest.url = urlPattern; rest.email = emailPattern; rest.strong = { pattern: /(^|[^\w*])(__|\*\*)(?![_*])[\s\S]*?\2(?=([^\w*]|$))/gm, lookbehind: true, inside: { 'cl cl-strong cl-start': /^(__|\*\*)/, 'cl cl-strong cl-close': /(__|\*\*)$/, }, }; rest.em = { pattern: /(^|[^\w*])(_|\*)(?![_*])[\s\S]*?\2(?=([^\w*]|$))/gm, lookbehind: true, inside: { 'cl cl-em cl-start': /^(_|\*)/, 'cl cl-em cl-close': /(_|\*)$/, }, }; rest['strong em'] = { pattern: /(^|[^\w*])(__|\*\*)(_|\*)(?![_*])[\s\S]*?\3\2(?=([^\w*]|$))/gm, lookbehind: true, inside: { 'cl cl-strong cl-start': /^(__|\*\*)(_|\*)/, 'cl cl-strong cl-close': /(_|\*)(__|\*\*)$/, }, }; rest['strong em inv'] = { pattern: /(^|[^\w*])(_|\*)(__|\*\*)(?![_*])[\s\S]*?\3\2(?=([^\w*]|$))/gm, lookbehind: true, inside: { 'cl cl-strong cl-start': /^(_|\*)(__|\*\*)/, 'cl cl-strong cl-close': /(__|\*\*)(_|\*)$/, }, }; if (options.del) { rest.del = { pattern: /(^|[^\w*])(~~)[\s\S]*?\2(?=([^\w*]|$))/gm, lookbehind: true, inside: { cl: /~~/, 'cl-del-text': /[^~]+/, }, }; } if (options.mark) { rest.mark = { pattern: /(^|[^\w*])(==)[\s\S]*?\2(?=([^\w*]|$))/gm, lookbehind: true, inside: { cl: /==/, 'cl-mark-text': /[^=]+/, }, }; } if (options.sub) { rest.sub = { pattern: /(~)(?=\S)(.*?\S)\1/gm, inside: { cl: /~/, }, }; } if (options.sup) { rest.sup = { pattern: /(\^)(?=\S)(.*?\S)\1/gm, inside: { cl: /\^/, }, }; } rest.entity = markup.entity; for (let c = 6; c >= 1; c -= 1) { grammars.main[`h${c}`].inside.rest = rest; } grammars.main['h1 alt'].inside.rest = rest; grammars.main['h2 alt'].inside.rest = rest; grammars.table.table.inside.rest = rest; grammars.main.rest = rest; grammars.list.rest = rest; grammars.blockquote.blockquote.inside.rest = rest; grammars.deflist.deflist.inside.rest = rest; if (options.footnote) { grammars.main.fndef.inside.rest = rest; } const restLight = { code: rest.code, inlinefn: rest.inlinefn, fn: rest.fn, link: rest.link, linkref: rest.linkref, }; rest.strong.inside.rest = restLight; rest.em.inside.rest = restLight; if (options.del) { rest.del.inside.rest = restLight; } if (options.mark) { rest.mark.inside.rest = restLight; } const inside = { code: rest.code, comment: rest.comment, tag: rest.tag, strong: rest.strong, em: rest.em, del: rest.del, sub: rest.sub, sup: rest.sup, entity: markup.entity, }; rest.link.inside['cl cl-underlined-text'].inside = inside; rest.linkref.inside['cl cl-underlined-text'].inside = inside; // Wrap any other characters to allow paragraph folding Object.entries(grammars).forEach(([, grammar]) => { grammar.rest = grammar.rest || {}; grammar.rest.p = /.+/; }); return grammars; }, }; ================================================ FILE: src/services/networkSvc.js ================================================ import utils from './utils'; import store from '../store'; import constants from '../data/constants'; const scriptLoadingPromises = Object.create(null); const authorizeTimeout = 6 * 60 * 1000; // 2 minutes const silentAuthorizeTimeout = 15 * 1000; // 15 secondes (which will be reattempted) const networkTimeout = 30 * 1000; // 30 sec let isConnectionDown = false; const userInactiveAfter = 3 * 60 * 1000; // 3 minutes (twice the default sync period) let lastActivity = 0; let lastFocus = 0; let isConfLoading = false; let isConfLoaded = false; function parseHeaders(xhr) { const pairs = xhr.getAllResponseHeaders().trim().split('\n'); const headers = {}; pairs.forEach((header) => { const split = header.trim().split(':'); const key = split.shift().trim().toLowerCase(); const value = split.join(':').trim(); headers[key] = value; }); return headers; } function isRetriable(err) { if (err.status === 403) { const googleReason = ((((err.body || {}).error || {}).errors || [])[0] || {}).reason; return googleReason === 'rateLimitExceeded' || googleReason === 'userRateLimitExceeded'; } return err.status === 429 || (err.status >= 500 && err.status < 600); } export default { async init() { // Keep track of the last user activity const setLastActivity = () => { lastActivity = Date.now(); }; window.document.addEventListener('mousedown', setLastActivity); window.document.addEventListener('keydown', setLastActivity); window.document.addEventListener('touchstart', setLastActivity); // Keep track of the last window focus lastFocus = 0; const setLastFocus = () => { lastFocus = Date.now(); localStorage.setItem(store.getters['workspace/lastFocusKey'], lastFocus); setLastActivity(); }; if (document.hasFocus()) { setLastFocus(); } window.addEventListener('focus', setLastFocus); // Check that browser is online periodically const checkOffline = async () => { const isBrowserOffline = window.navigator.onLine === false; if (!isBrowserOffline && store.state.lastOfflineCheck + networkTimeout + 5000 < Date.now() && this.isUserActive() ) { store.commit('updateLastOfflineCheck'); const script = document.createElement('script'); let timeout; try { await new Promise((resolve, reject) => { script.onload = resolve; script.onerror = reject; script.src = `https://apis.google.com/js/api.js?${Date.now()}`; try { document.head.appendChild(script); // This can fail with bad network timeout = setTimeout(reject, networkTimeout); } catch (e) { reject(e); } }); isConnectionDown = false; } catch (e) { isConnectionDown = true; } finally { clearTimeout(timeout); document.head.removeChild(script); } } const offline = isBrowserOffline || isConnectionDown; if (store.state.offline !== offline) { store.commit('setOffline', offline); if (offline) { store.dispatch('notification/error', 'You are offline.'); } else { store.dispatch('notification/info', 'You are back online!'); this.getServerConf(); } } }; utils.setInterval(checkOffline, 1000); window.addEventListener('online', () => { isConnectionDown = false; checkOffline(); }); window.addEventListener('offline', checkOffline); await checkOffline(); this.getServerConf(); }, async getServerConf() { if (!store.state.offline && !isConfLoading && !isConfLoaded) { try { isConfLoading = true; const res = await this.request({ url: 'conf' }); await store.dispatch('data/setServerConf', res.body); isConfLoaded = true; } finally { isConfLoading = false; } } }, isWindowFocused() { // We don't use state.workspace.lastFocus as it's not reactive const storedLastFocus = localStorage.getItem(store.getters['workspace/lastFocusKey']); return parseInt(storedLastFocus, 10) === lastFocus; }, isUserActive() { return lastActivity > Date.now() - userInactiveAfter && this.isWindowFocused(); }, isConfLoaded() { return !!Object.keys(store.getters['data/serverConf']).length; }, async loadScript(url) { if (!scriptLoadingPromises[url]) { scriptLoadingPromises[url] = new Promise((resolve, reject) => { const script = document.createElement('script'); script.onload = resolve; script.onerror = () => { scriptLoadingPromises[url] = null; reject(); }; script.src = url; document.head.appendChild(script); }); } return scriptLoadingPromises[url]; }, async startOauth2(url, params = {}, silent = false, reattempt = false) { try { // Build the authorize URL const state = utils.uid(); const authorizeUrl = utils.addQueryParams(url, { ...params, state, redirect_uri: constants.oauth2RedirectUri, }); let iframeElt; let wnd; if (silent) { // Use an iframe as wnd for silent mode iframeElt = utils.createHiddenIframe(authorizeUrl); document.body.appendChild(iframeElt); wnd = iframeElt.contentWindow; } else { // Open a tab otherwise wnd = window.open(authorizeUrl); if (!wnd) { throw new Error('The authorize window was blocked.'); } } let checkClosedInterval; let closeTimeout; let msgHandler; try { return await new Promise((resolve, reject) => { if (silent) { iframeElt.onerror = () => { reject(new Error('Unknown error.')); }; closeTimeout = setTimeout(() => { if (!reattempt) { reject(new Error('REATTEMPT')); } else { isConnectionDown = true; store.commit('setOffline', true); store.commit('updateLastOfflineCheck'); reject(new Error('You are offline.')); } }, silentAuthorizeTimeout); } else { closeTimeout = setTimeout(() => { reject(new Error('Timeout.')); }, authorizeTimeout); } msgHandler = (event) => { if (event.source === wnd && event.origin === constants.origin) { const data = utils.parseQueryParams(`${event.data}`.slice(1)); if (data.error || data.state !== state) { console.error(data); // eslint-disable-line no-console reject(new Error('Could not get required authorization.')); } else { resolve({ accessToken: data.access_token, code: data.code, idToken: data.id_token, expiresIn: data.expires_in, }); } } }; window.addEventListener('message', msgHandler); if (!silent) { checkClosedInterval = setInterval(() => { if (wnd.closed) { reject(new Error('Authorize window was closed.')); } }, 250); } }); } finally { clearInterval(checkClosedInterval); if (!silent && !wnd.closed) { wnd.close(); } if (iframeElt) { document.body.removeChild(iframeElt); } clearTimeout(closeTimeout); window.removeEventListener('message', msgHandler); } } catch (e) { if (e.message === 'REATTEMPT') { return this.startOauth2(url, params, silent, true); } throw e; } }, async request(config, offlineCheck = false) { let retryAfter = 500; // 500 ms const maxRetryAfter = 10 * 1000; // 10 sec const sanitizedConfig = Object.assign({}, config); sanitizedConfig.timeout = sanitizedConfig.timeout || networkTimeout; sanitizedConfig.headers = Object.assign({}, sanitizedConfig.headers); if (sanitizedConfig.body && typeof sanitizedConfig.body === 'object') { sanitizedConfig.body = JSON.stringify(sanitizedConfig.body); sanitizedConfig.headers['Content-Type'] = 'application/json'; } const attempt = async () => { try { return await new Promise((resolve, reject) => { if (offlineCheck) { store.commit('updateLastOfflineCheck'); } const xhr = new window.XMLHttpRequest(); xhr.withCredentials = sanitizedConfig.withCredentials || false; const timeoutId = setTimeout(() => { xhr.abort(); if (offlineCheck) { isConnectionDown = true; store.commit('setOffline', true); reject(new Error('You are offline.')); } else { reject(new Error('Network request timeout.')); } }, sanitizedConfig.timeout); xhr.onload = () => { if (offlineCheck) { isConnectionDown = false; } clearTimeout(timeoutId); const result = { status: xhr.status, headers: parseHeaders(xhr), body: sanitizedConfig.blob ? xhr.response : xhr.responseText, }; if (!sanitizedConfig.raw && !sanitizedConfig.blob) { try { result.body = JSON.parse(result.body); } catch (e) { // ignore } } if (result.status >= 200 && result.status < 300) { resolve(result); } else { reject(result); } }; xhr.onerror = () => { clearTimeout(timeoutId); if (offlineCheck) { isConnectionDown = true; store.commit('setOffline', true); reject(new Error('You are offline.')); } else { reject(new Error('Network request failed.')); } }; const url = utils.addQueryParams(sanitizedConfig.url, sanitizedConfig.params); xhr.open(sanitizedConfig.method || 'GET', url); Object.entries(sanitizedConfig.headers).forEach(([key, value]) => { if (value) { xhr.setRequestHeader(key, `${value}`); } }); if (sanitizedConfig.blob) { xhr.responseType = 'blob'; } xhr.send(sanitizedConfig.body || null); }); } catch (err) { // Try again later in case of retriable error if (isRetriable(err) && retryAfter < maxRetryAfter) { await new Promise((resolve) => { setTimeout(resolve, retryAfter); // Exponential backoff retryAfter *= 2; }); return attempt(); } throw err; } }; return attempt(); }, }; ================================================ FILE: src/services/optional/index.js ================================================ import './shortcuts'; import './keystrokes'; import './scrollSync'; import './taskChange'; ================================================ FILE: src/services/optional/keystrokes.js ================================================ import cledit from '../editor/cledit'; import editorSvc from '../editorSvc'; import store from '../../store'; const { Keystroke } = cledit; const indentRegexp = /^ {0,3}>[ ]*|^[ \t]*[*+-][ \t](?:\[[ xX]\][ \t])?|^([ \t]*)\d+\.[ \t](?:\[[ xX]\][ \t])?|^\s+/; let clearNewline; let lastSelection; function fixNumberedList(state, indent) { if (state.selection || indent === undefined || !store.getters['data/computedSettings'].editor.listAutoNumber ) { return; } const spaceIndent = indent.replace(/\t/g, ' '); const indentRegex = new RegExp(`^[ \\s]*$|^${spaceIndent}(\\d+\\.[ \\t])?(( )?.*)$`); function getHits(lines) { let hits = []; let pendingHits = []; function flush() { if (!pendingHits.hasHit && pendingHits.hasNoIndent) { return false; } hits = hits.concat(pendingHits); pendingHits = []; return true; } lines.some((line) => { const match = line.replace( /^[ \t]*/, wholeMatch => wholeMatch.replace(/\t/g, ' '), ).match(indentRegex); if (!match || line.match(/^#+ /)) { // Line not empty, not indented, or title flush(); return true; } pendingHits.push({ line, match, }); if (match[2] !== undefined) { if (match[1]) { pendingHits.hasHit = true; } else if (!match[3]) { pendingHits.hasNoIndent = true; } } else if (!flush()) { return true; } return false; }); return hits; } function formatHits(hits) { let num; return hits.map((hit) => { if (hit.match[1]) { if (!num) { num = parseInt(hit.match[1], 10); } const result = indent + num + hit.match[1].slice(-2) + hit.match[2]; num += 1; return result; } return hit.line; }); } const before = state.before.split('\n'); before.unshift(''); // Add an extra line (fixes #184) const after = state.after.split('\n'); let currentLine = before.pop() || ''; const currentPos = currentLine.length; currentLine += after.shift() || ''; let lines = before.concat(currentLine).concat(after); let idx = before.length - getHits(before.slice().reverse()).length; // Prevents starting from 0 while (idx <= before.length + 1) { const hits = formatHits(getHits(lines.slice(idx))); if (!hits.length) { idx += 1; } else { lines = lines.slice(0, idx).concat(hits).concat(lines.slice(idx + hits.length)); idx += hits.length; } } currentLine = lines[before.length]; state.before = lines.slice(1, before.length); // As we've added an extra line state.before.push(currentLine.slice(0, currentPos)); state.before = state.before.join('\n'); state.after = [currentLine.slice(currentPos)].concat(lines.slice(before.length + 1)); state.after = state.after.join('\n'); } function enterKeyHandler(evt, state) { if (evt.which !== 13) { // Not enter clearNewline = false; return false; } evt.preventDefault(); // Get the last line before the selection const lastLf = state.before.lastIndexOf('\n') + 1; const lastLine = state.before.slice(lastLf); // See if the line is indented const indentMatch = lastLine.match(indentRegexp) || ['']; if (clearNewline && !state.selection && state.before.length === lastSelection) { state.before = state.before.substring(0, lastLf); state.selection = ''; clearNewline = false; fixNumberedList(state, indentMatch[1]); return true; } clearNewline = false; const indent = indentMatch[0]; if (indent.length) { clearNewline = true; } editorSvc.clEditor.undoMgr.setCurrentMode('single'); state.before += `\n${indent}`; state.selection = ''; lastSelection = state.before.length; fixNumberedList(state, indentMatch[1]); return true; } function tabKeyHandler(evt, state) { if (evt.which !== 9 || evt.metaKey || evt.ctrlKey) { // Not tab return false; } const strSplice = (str, i, remove, add) => str.slice(0, i) + (add || '') + str.slice(i + (+remove || 0)); evt.preventDefault(); const isInverse = evt.shiftKey; const lastLf = state.before.lastIndexOf('\n') + 1; const lastLine = state.before.slice(lastLf); const currentLine = lastLine + state.selection + state.after; const indentMatch = currentLine.match(indentRegexp); if (isInverse) { const previousChar = state.before.slice(-1); if (/\s/.test(state.before.charAt(lastLf))) { state.before = strSplice(state.before, lastLf, 1); if (indentMatch) { fixNumberedList(state, indentMatch[1]); if (indentMatch[1]) { fixNumberedList(state, indentMatch[1].slice(1)); } } } const selection = previousChar + state.selection; state.selection = selection.replace(/\n[ \t]/gm, '\n'); if (previousChar) { state.selection = state.selection.slice(1); } } else if ( // If selection is not empty state.selection // Or we are in an indented paragraph and the cursor is over the indentation characters || (indentMatch && indentMatch[0].length >= lastLine.length) ) { state.before = strSplice(state.before, lastLf, 0, '\t'); state.selection = state.selection.replace(/\n(?=.)/g, '\n\t'); if (indentMatch) { fixNumberedList(state, indentMatch[1]); fixNumberedList(state, `\t${indentMatch[1]}`); } } else { state.before += '\t'; } return true; } editorSvc.$on('inited', () => { editorSvc.clEditor.addKeystroke(new Keystroke(enterKeyHandler, 50)); editorSvc.clEditor.addKeystroke(new Keystroke(tabKeyHandler, 50)); }); ================================================ FILE: src/services/optional/scrollSync.js ================================================ import store from '../../store'; import animationSvc from '../animationSvc'; import editorSvc from '../editorSvc'; let editorScrollerElt; let previewScrollerElt; let editorFinishTimeoutId; let previewFinishTimeoutId; let skipAnimation; let isScrollEditor; let isScrollPreview; let isEditorMoving; let isPreviewMoving; let sectionDescList = []; let throttleTimeoutId; let throttleLastTime = 0; function throttle(func, wait) { clearTimeout(throttleTimeoutId); const currentTime = Date.now(); const localWait = (wait + throttleLastTime) - currentTime; if (localWait < 1) { throttleLastTime = currentTime; func(); } else { throttleTimeoutId = setTimeout(() => { throttleLastTime = Date.now(); func(); }, localWait); } } const doScrollSync = () => { const localSkipAnimation = skipAnimation || !store.getters['layout/styles'].showSidePreview; skipAnimation = false; if (!store.getters['data/layoutSettings'].scrollSync || sectionDescList.length === 0) { return; } let editorScrollTop = editorScrollerElt.scrollTop; if (editorScrollTop < 0) { editorScrollTop = 0; } const previewScrollTop = previewScrollerElt.scrollTop; let scrollTo; if (isScrollEditor) { // Scroll the preview isScrollEditor = false; sectionDescList.some((sectionDesc) => { if (editorScrollTop > sectionDesc.editorDimension.endOffset) { return false; } const posInSection = (editorScrollTop - sectionDesc.editorDimension.startOffset) / (sectionDesc.editorDimension.height || 1); scrollTo = (sectionDesc.previewDimension.startOffset + (sectionDesc.previewDimension.height * posInSection)); return true; }); scrollTo = Math.min( scrollTo, previewScrollerElt.scrollHeight - previewScrollerElt.offsetHeight, ); throttle(() => { clearTimeout(previewFinishTimeoutId); animationSvc.animate(previewScrollerElt) .scrollTop(scrollTo) .duration(!localSkipAnimation && 100) .start(() => { previewFinishTimeoutId = setTimeout(() => { isPreviewMoving = false; }, 100); }, () => { isPreviewMoving = true; }); }, localSkipAnimation ? 500 : 50); } else if (!store.getters['layout/styles'].showEditor || isScrollPreview) { // Scroll the editor isScrollPreview = false; sectionDescList.some((sectionDesc) => { if (previewScrollTop > sectionDesc.previewDimension.endOffset) { return false; } const posInSection = (previewScrollTop - sectionDesc.previewDimension.startOffset) / (sectionDesc.previewDimension.height || 1); scrollTo = (sectionDesc.editorDimension.startOffset + (sectionDesc.editorDimension.height * posInSection)); return true; }); scrollTo = Math.min( scrollTo, editorScrollerElt.scrollHeight - editorScrollerElt.offsetHeight, ); throttle(() => { clearTimeout(editorFinishTimeoutId); animationSvc.animate(editorScrollerElt) .scrollTop(scrollTo) .duration(!localSkipAnimation && 100) .start(() => { editorFinishTimeoutId = setTimeout(() => { isEditorMoving = false; }, 100); }, () => { isEditorMoving = true; }); }, localSkipAnimation ? 500 : 50); } }; let isPreviewRefreshing; let timeoutId; const forceScrollSync = () => { if (!isPreviewRefreshing) { doScrollSync(); } }; store.watch(() => store.getters['data/layoutSettings'].scrollSync, forceScrollSync); editorSvc.$on('inited', () => { editorScrollerElt = editorSvc.editorElt.parentNode; previewScrollerElt = editorSvc.previewElt.parentNode; editorScrollerElt.addEventListener('scroll', () => { if (isEditorMoving) { return; } isScrollEditor = true; isScrollPreview = false; doScrollSync(); }); previewScrollerElt.addEventListener('scroll', () => { if (isPreviewMoving || isPreviewRefreshing) { return; } isScrollPreview = true; isScrollEditor = false; doScrollSync(); }); }); editorSvc.$on('sectionList', () => { clearTimeout(timeoutId); isPreviewRefreshing = true; sectionDescList = []; }); editorSvc.$on('previewCtx', () => { // Assume the user is writing in the editor isScrollEditor = store.getters['layout/styles'].showEditor; // A preview scrolling event can occur if height is smaller timeoutId = setTimeout(() => { isPreviewRefreshing = false; }, 100); }); store.watch( () => store.getters['layout/styles'].showEditor, (showEditor) => { isScrollEditor = showEditor; isScrollPreview = !showEditor; skipAnimation = true; }, ); store.watch( () => store.getters['file/current'].id, () => { skipAnimation = true; }, ); editorSvc.$on('previewCtxMeasured', (previewCtxMeasured) => { if (previewCtxMeasured) { ({ sectionDescList } = previewCtxMeasured); forceScrollSync(); } }); ================================================ FILE: src/services/optional/shortcuts.js ================================================ import Mousetrap from 'mousetrap'; import store from '../../store'; import editorSvc from '../../services/editorSvc'; import syncSvc from '../../services/syncSvc'; // Skip shortcuts if modal is open or editor is hidden Mousetrap.prototype.stopCallback = () => store.getters['modal/config'] || !store.getters['content/isCurrentEditable']; const pagedownHandler = name => () => { editorSvc.pagedownEditor.uiManager.doClick(name); return true; }; const findReplaceOpener = type => () => { store.dispatch('findReplace/open', { type, findText: editorSvc.clEditor.selectionMgr.hasFocus() && editorSvc.clEditor.selectionMgr.getSelectedText(), }); return true; }; const methods = { bold: pagedownHandler('bold'), italic: pagedownHandler('italic'), strikethrough: pagedownHandler('strikethrough'), link: pagedownHandler('link'), quote: pagedownHandler('quote'), code: pagedownHandler('code'), image: pagedownHandler('image'), olist: pagedownHandler('olist'), ulist: pagedownHandler('ulist'), clist: pagedownHandler('clist'), heading: pagedownHandler('heading'), hr: pagedownHandler('hr'), sync() { if (syncSvc.isSyncPossible()) { syncSvc.requestSync(); } return true; }, find: findReplaceOpener('find'), replace: findReplaceOpener('replace'), expand(param1, param2) { const text = `${param1 || ''}`; const replacement = `${param2 || ''}`; if (text && replacement) { setTimeout(() => { const { selectionMgr } = editorSvc.clEditor; let offset = selectionMgr.selectionStart; if (offset === selectionMgr.selectionEnd) { const range = selectionMgr.createRange(offset - text.length, offset); if (`${range}` === text) { range.deleteContents(); range.insertNode(document.createTextNode(replacement)); offset = (offset - text.length) + replacement.length; selectionMgr.setSelectionStartEnd(offset, offset); selectionMgr.updateCursorCoordinates(true); } } }, 1); } }, }; store.watch( () => store.getters['data/computedSettings'], (computedSettings) => { Mousetrap.reset(); Object.entries(computedSettings.shortcuts).forEach(([key, shortcut]) => { if (shortcut) { const method = `${shortcut.method || shortcut}`; let params = shortcut.params || []; if (!Array.isArray(params)) { params = [params]; } if (Object.prototype.hasOwnProperty.call(methods, method)) { try { Mousetrap.bind(`${key}`, () => !methods[method].apply(null, params)); } catch (e) { // Ignore } } } }); }, { immediate: true, }, ); ================================================ FILE: src/services/optional/taskChange.js ================================================ import editorSvc from '../editorSvc'; import store from '../../store'; editorSvc.$on('inited', () => { const getPreviewOffset = (elt) => { let offset = 0; if (!elt || elt === editorSvc.previewElt) { return offset; } let { previousSibling } = elt; while (previousSibling) { offset += previousSibling.textContent.length; ({ previousSibling } = previousSibling); } return offset + getPreviewOffset(elt.parentNode); }; editorSvc.previewElt.addEventListener('click', (evt) => { if (evt.target.classList.contains('task-list-item-checkbox')) { evt.preventDefault(); if (store.getters['content/isCurrentEditable']) { const editorContent = editorSvc.clEditor.getContent(); // Use setTimeout to ensure evt.target.checked has the old value setTimeout(() => { // Make sure content has not changed if (editorContent === editorSvc.clEditor.getContent()) { const previewOffset = getPreviewOffset(evt.target); const endOffset = editorSvc.getEditorOffset(previewOffset + 1); if (endOffset != null) { const startOffset = editorContent.lastIndexOf('\n', endOffset) + 1; const line = editorContent.slice(startOffset, endOffset); const match = line.match(/^([ \t]*(?:[*+-]|\d+\.)[ \t]+\[)[ xX](\] .*)/); if (match) { let newContent = editorContent.slice(0, startOffset); newContent += match[1]; newContent += evt.target.checked ? ' ' : 'x'; newContent += match[2]; newContent += editorContent.slice(endOffset); editorSvc.clEditor.setContent(newContent, true); } } } }, 10); } } }); }); ================================================ FILE: src/services/providers/bloggerPageProvider.js ================================================ import store from '../../store'; import googleHelper from './helpers/googleHelper'; import Provider from './common/Provider'; export default new Provider({ id: 'bloggerPage', name: 'Blogger Page', getToken({ sub }) { const token = store.getters['data/googleTokensBySub'][sub]; return token && token.isBlogger ? token : null; }, getLocationUrl({ blogId, pageId }) { return `https://www.blogger.com/blogger.g?blogID=${blogId}#editor/target=page;pageID=${pageId}`; }, getLocationDescription({ pageId }) { return pageId; }, async publish(token, html, metadata, publishLocation) { const page = await googleHelper.uploadBlogger({ token, blogUrl: publishLocation.blogUrl, blogId: publishLocation.blogId, postId: publishLocation.pageId, title: metadata.title, content: html, isPage: true, }); return { ...publishLocation, blogId: page.blog.id, pageId: page.id, }; }, makeLocation(token, blogUrl, pageId) { const location = { providerId: this.id, sub: token.sub, blogUrl, }; if (pageId) { location.pageId = pageId; } return location; }, }); ================================================ FILE: src/services/providers/bloggerProvider.js ================================================ import store from '../../store'; import googleHelper from './helpers/googleHelper'; import Provider from './common/Provider'; export default new Provider({ id: 'blogger', name: 'Blogger', getToken({ sub }) { const token = store.getters['data/googleTokensBySub'][sub]; return token && token.isBlogger ? token : null; }, getLocationUrl({ blogId, postId }) { return `https://www.blogger.com/blogger.g?blogID=${blogId}#editor/target=post;postID=${postId}`; }, getLocationDescription({ postId }) { return postId; }, async publish(token, html, metadata, publishLocation) { const post = await googleHelper.uploadBlogger({ ...publishLocation, token, title: metadata.title, content: html, labels: metadata.tags, isDraft: metadata.status === 'draft', published: metadata.date, }); return { ...publishLocation, blogId: post.blog.id, postId: post.id, }; }, makeLocation(token, blogUrl, postId) { const location = { providerId: this.id, sub: token.sub, blogUrl, }; if (postId) { location.postId = postId; } return location; }, }); ================================================ FILE: src/services/providers/common/Provider.js ================================================ import providerRegistry from './providerRegistry'; import emptyContent from '../../../data/empties/emptyContent'; import utils from '../../utils'; import store from '../../../store'; import workspaceSvc from '../../workspaceSvc'; const dataExtractor = /\s*$/; export default class Provider { prepareChanges = changes => changes onChangesApplied = () => {} constructor(props) { Object.assign(this, props); providerRegistry.register(this); } /** * Serialize content in a self contain Markdown compatible format */ static serializeContent(content) { let result = content.text; const data = {}; if (content.properties.length > 1) { data.properties = content.properties; } if (Object.keys(content.discussions).length) { data.discussions = content.discussions; } if (Object.keys(content.comments).length) { data.comments = content.comments; } if (content.history && content.history.length) { data.history = content.history; } if (Object.keys(data).length) { const serializedData = utils.encodeBase64(JSON.stringify(data)).replace(/(.{50})/g, '$1\n'); result += ``; } return result; } /** * Parse content serialized with serializeContent() */ static parseContent(serializedContent, id) { let text = serializedContent; const extractedData = dataExtractor.exec(serializedContent); let result; if (!extractedData) { // In case stackedit's data has been manually removed, try to restore them result = utils.deepCopy(store.state.content.itemsById[id]) || emptyContent(id); } else { result = emptyContent(id); try { const serializedData = extractedData[1].replace(/\s/g, ''); const parsedData = JSON.parse(utils.decodeBase64(serializedData)); text = text.slice(0, extractedData.index); if (parsedData.properties) { result.properties = utils.sanitizeText(parsedData.properties); } if (parsedData.discussions) { result.discussions = parsedData.discussions; } if (parsedData.comments) { result.comments = parsedData.comments; } result.history = parsedData.history; } catch (e) { // Ignore } } result.text = utils.sanitizeText(text); if (!result.history) { result.history = []; } return utils.addItemHash(result); } /** * Find and open a file with location that meets the criteria */ static openFileWithLocation(criteria) { const location = utils.search(store.getters['syncLocation/items'], criteria); if (location) { // Found one, open it if it exists const item = store.state.file.itemsById[location.fileId]; if (item) { store.commit('file/setCurrentId', item.id); // If file is in the trash, restore it if (item.parentId === 'trash') { workspaceSvc.setOrPatchItem({ ...item, parentId: null, }); } return true; } } return false; } } ================================================ FILE: src/services/providers/common/providerRegistry.js ================================================ export default { providersById: {}, register(provider) { this.providersById[provider.id] = provider; return provider; }, }; ================================================ FILE: src/services/providers/couchdbWorkspaceProvider.js ================================================ import store from '../../store'; import couchdbHelper from './helpers/couchdbHelper'; import Provider from './common/Provider'; import utils from '../utils'; import badgeSvc from '../badgeSvc'; let syncLastSeq; export default new Provider({ id: 'couchdbWorkspace', name: 'CouchDB', getToken() { return store.getters['workspace/syncToken']; }, getWorkspaceParams({ dbUrl }) { return { providerId: this.id, dbUrl, }; }, getWorkspaceLocationUrl({ dbUrl }) { return dbUrl; }, getSyncDataUrl(fileSyncData, { id }) { const { dbUrl } = this.getToken(); return `${dbUrl}/${id}/data`; }, getSyncDataDescription(fileSyncData, { id }) { return id; }, async initWorkspace() { const dbUrl = (utils.queryParams.dbUrl || '').replace(/\/?$/, ''); // Remove trailing / const workspaceParams = this.getWorkspaceParams({ dbUrl }); const workspaceId = utils.makeWorkspaceId(workspaceParams); // Create the token if it doesn't exist if (!store.getters['data/couchdbTokensBySub'][workspaceId]) { store.dispatch('data/addCouchdbToken', { sub: workspaceId, dbUrl, }); } // Create the workspace if it doesn't exist if (!store.getters['workspace/workspacesById'][workspaceId]) { try { // Make sure the database exists and retrieve its name const db = await couchdbHelper.getDb(store.getters['data/couchdbTokensBySub'][workspaceId]); store.dispatch('workspace/patchWorkspacesById', { [workspaceId]: { id: workspaceId, name: db.db_name, providerId: this.id, dbUrl, }, }); } catch (e) { throw new Error(`${dbUrl} is not accessible. Make sure you have the proper permissions.`); } } badgeSvc.addBadge('addCouchdbWorkspace'); return store.getters['workspace/workspacesById'][workspaceId]; }, async getChanges() { const syncToken = store.getters['workspace/syncToken']; const lastSeq = store.getters['data/localSettings'].syncLastSeq; const result = await couchdbHelper.getChanges(syncToken, lastSeq); const changes = result.changes.filter((change) => { if (!change.deleted && change.doc) { change.item = change.doc.item; if (!change.item || !change.item.id || !change.item.type) { return false; } // Build sync data change.syncData = { id: change.id, itemId: change.item.id, type: change.item.type, hash: change.item.hash, rev: change.doc._rev, // eslint-disable-line no-underscore-dangle }; } change.syncDataId = change.id; return true; }); syncLastSeq = result.lastSeq; return changes; }, onChangesApplied() { store.dispatch('data/patchLocalSettings', { syncLastSeq, }); }, async saveWorkspaceItem({ item, syncData }) { const syncToken = store.getters['workspace/syncToken']; const { id, rev } = await couchdbHelper.uploadDocument({ token: syncToken, item, documentId: syncData && syncData.id, rev: syncData && syncData.rev, }); // Build sync data to save return { syncData: { id, itemId: item.id, type: item.type, hash: item.hash, rev, }, }; }, removeWorkspaceItem({ syncData }) { const syncToken = store.getters['workspace/syncToken']; return couchdbHelper.removeDocument(syncToken, syncData.id, syncData.rev); }, async downloadWorkspaceContent({ token, contentSyncData }) { const body = await couchdbHelper.retrieveDocumentWithAttachments(token, contentSyncData.id); const rev = body._rev; // eslint-disable-line no-underscore-dangle const content = Provider.parseContent(body.attachments.data, body.item.id); return { content, contentSyncData: { ...contentSyncData, hash: content.hash, rev, }, }; }, async downloadWorkspaceData({ token, syncData }) { if (!syncData) { return {}; } const body = await couchdbHelper.retrieveDocumentWithAttachments(token, syncData.id); const item = utils.addItemHash(JSON.parse(body.attachments.data)); const rev = body._rev; // eslint-disable-line no-underscore-dangle return { item, syncData: { ...syncData, hash: item.hash, rev, }, }; }, async uploadWorkspaceContent({ token, content, contentSyncData }) { const res = await couchdbHelper.uploadDocument({ token, item: { id: content.id, type: content.type, hash: content.hash, }, data: Provider.serializeContent(content), dataType: 'text/plain', documentId: contentSyncData && contentSyncData.id, rev: contentSyncData && contentSyncData.rev, }); // Return new sync data return { contentSyncData: { id: res.id, itemId: content.id, type: content.type, hash: content.hash, rev: res.rev, }, }; }, async uploadWorkspaceData({ token, item, syncData }) { const res = await couchdbHelper.uploadDocument({ token, item: { id: item.id, type: item.type, hash: item.hash, }, data: JSON.stringify(item), dataType: 'application/json', documentId: syncData && syncData.id, rev: syncData && syncData.rev, }); // Return new sync data return { syncData: { id: res.id, itemId: item.id, type: item.type, hash: item.hash, rev: res.rev, }, }; }, async listFileRevisions({ token, contentSyncDataId }) { const body = await couchdbHelper.retrieveDocumentWithRevisions(token, contentSyncDataId); const revisions = []; body._revs_info.forEach((revInfo, idx) => { // eslint-disable-line no-underscore-dangle if (revInfo.status === 'available') { revisions.push({ id: revInfo.rev, sub: null, created: idx, loaded: false, }); } }); return revisions; }, async loadFileRevision({ token, contentSyncDataId, revision }) { if (revision.loaded) { return false; } const body = await couchdbHelper.retrieveDocument(token, contentSyncDataId, revision.id); revision.sub = body.sub; revision.created = body.time; revision.loaded = true; return true; }, async getFileRevisionContent({ token, contentSyncDataId, revisionId }) { const body = await couchdbHelper .retrieveDocumentWithAttachments(token, contentSyncDataId, revisionId); return Provider.parseContent(body.attachments.data, body.item.id); }, }); ================================================ FILE: src/services/providers/dropboxProvider.js ================================================ import store from '../../store'; import dropboxHelper from './helpers/dropboxHelper'; import Provider from './common/Provider'; import utils from '../utils'; import workspaceSvc from '../workspaceSvc'; const makePathAbsolute = (token, path) => { if (!token.fullAccess) { return `/Applications/StackEdit (restricted)${path}`; } return path; }; const makePathRelative = (token, path) => { if (!token.fullAccess) { return path.replace(/^\/Applications\/StackEdit \(restricted\)/, ''); } return path; }; export default new Provider({ id: 'dropbox', name: 'Dropbox', getToken({ sub }) { return store.getters['data/dropboxTokensBySub'][sub]; }, getLocationUrl({ path }) { const pathComponents = path.split('/').map(encodeURIComponent); const filename = pathComponents.pop(); return `https://www.dropbox.com/home${pathComponents.join('/')}?preview=${filename}`; }, getLocationDescription({ path, dropboxFileId }) { return dropboxFileId || path; }, checkPath(path) { return path && path.match(/^\/[^\\<>:"|?*]+$/); }, async downloadContent(token, syncLocation) { const { content } = await dropboxHelper.downloadFile({ token, path: makePathRelative(token, syncLocation.path), fileId: syncLocation.dropboxFileId, }); return Provider.parseContent(content, `${syncLocation.fileId}/content`); }, async uploadContent(token, content, syncLocation) { const dropboxFile = await dropboxHelper.uploadFile({ token, path: makePathRelative(token, syncLocation.path), content: Provider.serializeContent(content), fileId: syncLocation.dropboxFileId, }); return { ...syncLocation, path: makePathAbsolute(token, dropboxFile.path_display), dropboxFileId: dropboxFile.id, }; }, async publish(token, html, metadata, publishLocation) { const dropboxFile = await dropboxHelper.uploadFile({ token, path: publishLocation.path, content: html, fileId: publishLocation.dropboxFileId, }); return { ...publishLocation, path: makePathAbsolute(token, dropboxFile.path_display), dropboxFileId: dropboxFile.id, }; }, async openFiles(token, paths) { await utils.awaitSequence(paths, async (path) => { // Check if the file exists and open it if (!Provider.openFileWithLocation({ providerId: this.id, path, })) { // Download content from Dropbox const syncLocation = { path, providerId: this.id, sub: token.sub, }; let content; try { content = await this.downloadContent(token, syncLocation); } catch (e) { store.dispatch('notification/error', `Could not open file ${path}.`); return; } // Create the file let name = path; const slashPos = name.lastIndexOf('/'); if (slashPos > -1 && slashPos < name.length - 1) { name = name.slice(slashPos + 1); } const dotPos = name.lastIndexOf('.'); if (dotPos > 0 && slashPos < name.length) { name = name.slice(0, dotPos); } const item = await workspaceSvc.createFile({ name, parentId: store.getters['file/current'].parentId, text: content.text, properties: content.properties, discussions: content.discussions, comments: content.comments, }, true); store.commit('file/setCurrentId', item.id); workspaceSvc.addSyncLocation({ ...syncLocation, fileId: item.id, }); store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from Dropbox.`); } }); }, makeLocation(token, path) { return { providerId: this.id, sub: token.sub, path, }; }, async listFileRevisions({ token, syncLocation }) { const entries = await dropboxHelper.listRevisions({ token, path: makePathRelative(token, syncLocation.path), fileId: syncLocation.dropboxFileId, }); return entries.map(entry => ({ id: entry.rev, sub: `${dropboxHelper.subPrefix}:${(entry.sharing_info || {}).modified_by || token.sub}`, created: new Date(entry.server_modified).getTime(), })); }, async loadFileRevision() { // Revision are already loaded return false; }, async getFileRevisionContent({ token, contentId, revisionId, }) { const { content } = await dropboxHelper.downloadFile({ token, path: `rev:${revisionId}`, }); return Provider.parseContent(content, contentId); }, }); ================================================ FILE: src/services/providers/gistProvider.js ================================================ import store from '../../store'; import githubHelper from './helpers/githubHelper'; import Provider from './common/Provider'; import utils from '../utils'; import userSvc from '../userSvc'; export default new Provider({ id: 'gist', name: 'Gist', getToken({ sub }) { return store.getters['data/githubTokensBySub'][sub]; }, getLocationUrl({ gistId }) { return `https://gist.github.com/${gistId}`; }, getLocationDescription({ filename }) { return filename; }, async downloadContent(token, syncLocation) { const content = await githubHelper.downloadGist({ ...syncLocation, token, }); return Provider.parseContent(content, `${syncLocation.fileId}/content`); }, async uploadContent(token, content, syncLocation) { const file = store.state.file.itemsById[syncLocation.fileId]; const description = utils.sanitizeName(file && file.name); const gist = await githubHelper.uploadGist({ ...syncLocation, token, description, content: Provider.serializeContent(content), }); return { ...syncLocation, gistId: gist.id, }; }, async publish(token, html, metadata, publishLocation) { const gist = await githubHelper.uploadGist({ ...publishLocation, token, description: metadata.title, content: html, }); return { ...publishLocation, gistId: gist.id, }; }, makeLocation(token, filename, isPublic, gistId) { return { providerId: this.id, sub: token.sub, filename, isPublic, gistId, }; }, async listFileRevisions({ token, syncLocation }) { const entries = await githubHelper.getGistCommits({ ...syncLocation, token, }); return entries.map((entry) => { const sub = `${githubHelper.subPrefix}:${entry.user.id}`; userSvc.addUserInfo({ id: sub, name: entry.user.login, imageUrl: entry.user.avatar_url }); return { sub, id: entry.version, created: new Date(entry.committed_at).getTime(), }; }); }, async loadFileRevision() { // Revision are already loaded return false; }, async getFileRevisionContent({ token, contentId, syncLocation, revisionId, }) { const data = await githubHelper.downloadGistRevision({ ...syncLocation, token, sha: revisionId, }); return Provider.parseContent(data, contentId); }, }); ================================================ FILE: src/services/providers/githubProvider.js ================================================ import store from '../../store'; import githubHelper from './helpers/githubHelper'; import Provider from './common/Provider'; import utils from '../utils'; import workspaceSvc from '../workspaceSvc'; import userSvc from '../userSvc'; const savedSha = {}; export default new Provider({ id: 'github', name: 'GitHub', getToken({ sub }) { return store.getters['data/githubTokensBySub'][sub]; }, getLocationUrl({ owner, repo, branch, path, }) { return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`; }, getLocationDescription({ path }) { return path; }, async downloadContent(token, syncLocation) { const { sha, data } = await githubHelper.downloadFile({ ...syncLocation, token, }); savedSha[syncLocation.id] = sha; return Provider.parseContent(data, `${syncLocation.fileId}/content`); }, async uploadContent(token, content, syncLocation) { if (!savedSha[syncLocation.id]) { try { // Get the last sha await this.downloadContent(token, syncLocation); } catch (e) { // Ignore error } } const sha = savedSha[syncLocation.id]; delete savedSha[syncLocation.id]; await githubHelper.uploadFile({ ...syncLocation, token, content: Provider.serializeContent(content), sha, }); return syncLocation; }, async publish(token, html, metadata, publishLocation) { try { // Get the last sha await this.downloadContent(token, publishLocation); } catch (e) { // Ignore error } const sha = savedSha[publishLocation.id]; delete savedSha[publishLocation.id]; await githubHelper.uploadFile({ ...publishLocation, token, content: html, sha, }); return publishLocation; }, async openFile(token, syncLocation) { // Check if the file exists and open it if (!Provider.openFileWithLocation(syncLocation)) { // Download content from GitHub let content; try { content = await this.downloadContent(token, syncLocation); } catch (e) { store.dispatch('notification/error', `Could not open file ${syncLocation.path}.`); return; } // Create the file let name = syncLocation.path; const slashPos = name.lastIndexOf('/'); if (slashPos > -1 && slashPos < name.length - 1) { name = name.slice(slashPos + 1); } const dotPos = name.lastIndexOf('.'); if (dotPos > 0 && slashPos < name.length) { name = name.slice(0, dotPos); } const item = await workspaceSvc.createFile({ name, parentId: store.getters['file/current'].parentId, text: content.text, properties: content.properties, discussions: content.discussions, comments: content.comments, }, true); store.commit('file/setCurrentId', item.id); workspaceSvc.addSyncLocation({ ...syncLocation, fileId: item.id, }); store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from GitHub.`); } }, makeLocation(token, owner, repo, branch, path) { return { providerId: this.id, sub: token.sub, owner, repo, branch, path, }; }, async listFileRevisions({ token, syncLocation }) { const entries = await githubHelper.getCommits({ ...syncLocation, token, }); return entries.map(({ author, committer, commit, sha, }) => { let user; if (author && author.login) { user = author; } else if (committer && committer.login) { user = committer; } const sub = `${githubHelper.subPrefix}:${user.id}`; userSvc.addUserInfo({ id: sub, name: user.login, imageUrl: user.avatar_url }); const date = (commit.author && commit.author.date) || (commit.committer && commit.committer.date); return { id: sha, sub, created: date ? new Date(date).getTime() : 1, }; }); }, async loadFileRevision() { // Revision are already loaded return false; }, async getFileRevisionContent({ token, contentId, syncLocation, revisionId, }) { const { data } = await githubHelper.downloadFile({ ...syncLocation, token, branch: revisionId, }); return Provider.parseContent(data, contentId); }, }); ================================================ FILE: src/services/providers/githubWorkspaceProvider.js ================================================ import store from '../../store'; import githubHelper from './helpers/githubHelper'; import Provider from './common/Provider'; import utils from '../utils'; import userSvc from '../userSvc'; import gitWorkspaceSvc from '../gitWorkspaceSvc'; import badgeSvc from '../badgeSvc'; const getAbsolutePath = ({ id }) => `${store.getters['workspace/currentWorkspace'].path || ''}${id}`; export default new Provider({ id: 'githubWorkspace', name: 'GitHub', getToken() { return store.getters['workspace/syncToken']; }, getWorkspaceParams({ owner, repo, branch, path, }) { return { providerId: this.id, owner, repo, branch, path, }; }, getWorkspaceLocationUrl({ owner, repo, branch, path, }) { return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`; }, getSyncDataUrl({ id }) { const { owner, repo, branch } = store.getters['workspace/currentWorkspace']; return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(getAbsolutePath({ id }))}`; }, getSyncDataDescription({ id }) { return getAbsolutePath({ id }); }, async initWorkspace() { const { owner, repo, branch } = utils.queryParams; const workspaceParams = this.getWorkspaceParams({ owner, repo, branch }); if (!branch) { workspaceParams.branch = 'master'; } // Extract path param const path = (utils.queryParams.path || '') .trim() .replace(/^\/*/, '') // Remove leading `/` .replace(/\/*$/, '/'); // Add trailing `/` if (path !== '/') { workspaceParams.path = path; } const workspaceId = utils.makeWorkspaceId(workspaceParams); const workspace = store.getters['workspace/workspacesById'][workspaceId]; // See if we already have a token let token; if (workspace) { // Token sub is in the workspace token = store.getters['data/githubTokensBySub'][workspace.sub]; } if (!token) { await store.dispatch('modal/open', { type: 'githubAccount' }); token = await githubHelper.addAccount(store.getters['data/localSettings'].githubRepoFullAccess); } if (!workspace) { const pathEntries = (path || '').split('/'); const name = pathEntries[pathEntries.length - 2] || repo; // path ends with `/` store.dispatch('workspace/patchWorkspacesById', { [workspaceId]: { ...workspaceParams, id: workspaceId, sub: token.sub, name, }, }); } badgeSvc.addBadge('addGithubWorkspace'); return store.getters['workspace/workspacesById'][workspaceId]; }, getChanges() { return githubHelper.getTree({ ...store.getters['workspace/currentWorkspace'], token: this.getToken(), }); }, prepareChanges(tree) { return gitWorkspaceSvc.makeChanges(tree); }, async saveWorkspaceItem({ item }) { const syncData = { id: store.getters.gitPathsByItemId[item.id], type: item.type, hash: item.hash, }; // Files and folders are not in git, only contents if (item.type === 'file' || item.type === 'folder') { return { syncData }; } // locations are stored as paths, so we upload an empty file const syncToken = store.getters['workspace/syncToken']; await githubHelper.uploadFile({ ...store.getters['workspace/currentWorkspace'], token: syncToken, path: getAbsolutePath(syncData), content: '', sha: gitWorkspaceSvc.shaByPath[syncData.id], }); // Return sync data to save return { syncData }; }, async removeWorkspaceItem({ syncData }) { if (gitWorkspaceSvc.shaByPath[syncData.id]) { const syncToken = store.getters['workspace/syncToken']; await githubHelper.removeFile({ ...store.getters['workspace/currentWorkspace'], token: syncToken, path: getAbsolutePath(syncData), sha: gitWorkspaceSvc.shaByPath[syncData.id], }); } }, async downloadWorkspaceContent({ token, contentId, contentSyncData, fileSyncData, }) { const { sha, data } = await githubHelper.downloadFile({ ...store.getters['workspace/currentWorkspace'], token, path: getAbsolutePath(fileSyncData), }); gitWorkspaceSvc.shaByPath[fileSyncData.id] = sha; const content = Provider.parseContent(data, contentId); return { content, contentSyncData: { ...contentSyncData, hash: content.hash, sha, }, }; }, async downloadWorkspaceData({ token, syncData }) { if (!syncData) { return {}; } const { sha, data } = await githubHelper.downloadFile({ ...store.getters['workspace/currentWorkspace'], token, path: getAbsolutePath(syncData), }); gitWorkspaceSvc.shaByPath[syncData.id] = sha; const item = JSON.parse(data); return { item, syncData: { ...syncData, hash: item.hash, sha, }, }; }, async uploadWorkspaceContent({ token, content, file }) { const path = store.getters.gitPathsByItemId[file.id]; const absolutePath = `${store.getters['workspace/currentWorkspace'].path || ''}${path}`; const res = await githubHelper.uploadFile({ ...store.getters['workspace/currentWorkspace'], token, path: absolutePath, content: Provider.serializeContent(content), sha: gitWorkspaceSvc.shaByPath[path], }); // Return new sync data return { contentSyncData: { id: store.getters.gitPathsByItemId[content.id], type: content.type, hash: content.hash, sha: res.content.sha, }, fileSyncData: { id: path, type: 'file', hash: file.hash, }, }; }, async uploadWorkspaceData({ token, item }) { const path = store.getters.gitPathsByItemId[item.id]; const syncData = { id: path, type: item.type, hash: item.hash, }; const res = await githubHelper.uploadFile({ ...store.getters['workspace/currentWorkspace'], token, path: getAbsolutePath(syncData), content: JSON.stringify(item), sha: gitWorkspaceSvc.shaByPath[path], }); return { syncData: { ...syncData, sha: res.content.sha, }, }; }, async listFileRevisions({ token, fileSyncDataId }) { const { owner, repo, branch } = store.getters['workspace/currentWorkspace']; const entries = await githubHelper.getCommits({ token, owner, repo, sha: branch, path: getAbsolutePath({ id: fileSyncDataId }), }); return entries.map(({ author, committer, commit, sha, }) => { let user; if (author && author.login) { user = author; } else if (committer && committer.login) { user = committer; } const sub = `${githubHelper.subPrefix}:${user.id}`; userSvc.addUserInfo({ id: sub, name: user.login, imageUrl: user.avatar_url }); const date = (commit.author && commit.author.date) || (commit.committer && commit.committer.date) || 1; return { id: sha, sub, created: new Date(date).getTime(), }; }); }, async loadFileRevision() { // Revisions are already loaded return false; }, async getFileRevisionContent({ token, contentId, fileSyncDataId, revisionId, }) { const { data } = await githubHelper.downloadFile({ ...store.getters['workspace/currentWorkspace'], token, branch: revisionId, path: getAbsolutePath({ id: fileSyncDataId }), }); return Provider.parseContent(data, contentId); }, }); ================================================ FILE: src/services/providers/gitlabProvider.js ================================================ import store from '../../store'; import gitlabHelper from './helpers/gitlabHelper'; import Provider from './common/Provider'; import utils from '../utils'; import workspaceSvc from '../workspaceSvc'; import userSvc from '../userSvc'; const savedSha = {}; export default new Provider({ id: 'gitlab', name: 'GitLab', getToken({ sub }) { return store.getters['data/gitlabTokensBySub'][sub]; }, getLocationUrl({ sub, projectPath, branch, path, }) { const token = this.getToken({ sub }); return `${token.serverUrl}/${projectPath}/blob/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`; }, getLocationDescription({ path }) { return path; }, async downloadContent(token, syncLocation) { const { sha, data } = await gitlabHelper.downloadFile({ ...syncLocation, token, }); savedSha[syncLocation.id] = sha; return Provider.parseContent(data, `${syncLocation.fileId}/content`); }, async uploadContent(token, content, syncLocation) { const updatedSyncLocation = { ...syncLocation, projectId: await gitlabHelper.getProjectId(token, syncLocation), }; if (!savedSha[updatedSyncLocation.id]) { try { // Get the last sha await this.downloadContent(token, updatedSyncLocation); } catch (e) { // Ignore error } } const sha = savedSha[updatedSyncLocation.id]; delete savedSha[updatedSyncLocation.id]; await gitlabHelper.uploadFile({ ...updatedSyncLocation, token, content: Provider.serializeContent(content), sha, }); return updatedSyncLocation; }, async publish(token, html, metadata, publishLocation) { const updatedPublishLocation = { ...publishLocation, projectId: await gitlabHelper.getProjectId(token, publishLocation), }; try { // Get the last sha await this.downloadContent(token, updatedPublishLocation); } catch (e) { // Ignore error } const sha = savedSha[updatedPublishLocation.id]; delete savedSha[updatedPublishLocation.id]; await gitlabHelper.uploadFile({ ...updatedPublishLocation, token, content: html, sha, }); return updatedPublishLocation; }, async openFile(token, syncLocation) { const updatedSyncLocation = { ...syncLocation, projectId: await gitlabHelper.getProjectId(token, syncLocation), }; // Check if the file exists and open it if (!Provider.openFileWithLocation(updatedSyncLocation)) { // Download content from GitLab let content; try { content = await this.downloadContent(token, updatedSyncLocation); } catch (e) { store.dispatch('notification/error', `Could not open file ${updatedSyncLocation.path}.`); return; } // Create the file let name = updatedSyncLocation.path; const slashPos = name.lastIndexOf('/'); if (slashPos > -1 && slashPos < name.length - 1) { name = name.slice(slashPos + 1); } const dotPos = name.lastIndexOf('.'); if (dotPos > 0 && slashPos < name.length) { name = name.slice(0, dotPos); } const item = await workspaceSvc.createFile({ name, parentId: store.getters['file/current'].parentId, text: content.text, properties: content.properties, discussions: content.discussions, comments: content.comments, }, true); store.commit('file/setCurrentId', item.id); workspaceSvc.addSyncLocation({ ...updatedSyncLocation, fileId: item.id, }); store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from GitLab.`); } }, makeLocation(token, projectPath, branch, path) { return { providerId: this.id, sub: token.sub, projectPath, branch, path, }; }, async listFileRevisions({ token, syncLocation }) { const entries = await gitlabHelper.getCommits({ ...syncLocation, token, }); return entries.map((entry) => { const email = entry.author_email || entry.committer_email; const sub = `${gitlabHelper.subPrefix}:${token.serverUrl}/${email}`; userSvc.addUserInfo({ id: sub, name: entry.author_name || entry.committer_name, imageUrl: '', }); const date = entry.authored_date || entry.committed_date || 1; return { id: entry.id, sub, created: date ? new Date(date).getTime() : 1, }; }); }, async loadFileRevision() { // Revision are already loaded return false; }, async getFileRevisionContent({ token, contentId, syncLocation, revisionId, }) { const { data } = await gitlabHelper.downloadFile({ ...syncLocation, token, branch: revisionId, }); return Provider.parseContent(data, contentId); }, }); ================================================ FILE: src/services/providers/gitlabWorkspaceProvider.js ================================================ import store from '../../store'; import gitlabHelper from './helpers/gitlabHelper'; import Provider from './common/Provider'; import utils from '../utils'; import userSvc from '../userSvc'; import gitWorkspaceSvc from '../gitWorkspaceSvc'; import badgeSvc from '../badgeSvc'; const getAbsolutePath = ({ id }) => `${store.getters['workspace/currentWorkspace'].path || ''}${id}`; export default new Provider({ id: 'gitlabWorkspace', name: 'GitLab', getToken() { return store.getters['workspace/syncToken']; }, getWorkspaceParams({ serverUrl, projectPath, branch, path, }) { return { providerId: this.id, serverUrl, projectPath, branch, path, }; }, getWorkspaceLocationUrl({ serverUrl, projectPath, branch, path, }) { return `${serverUrl}/${projectPath}/blob/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`; }, getSyncDataUrl({ id }) { const { projectPath, branch } = store.getters['workspace/currentWorkspace']; const { serverUrl } = this.getToken(); return `${serverUrl}/${projectPath}/blob/${encodeURIComponent(branch)}/${utils.encodeUrlPath(getAbsolutePath({ id }))}`; }, getSyncDataDescription({ id }) { return getAbsolutePath({ id }); }, async initWorkspace() { const { serverUrl, branch } = utils.queryParams; const workspaceParams = this.getWorkspaceParams({ serverUrl, branch }); if (!branch) { workspaceParams.branch = 'master'; } // Extract project path param const projectPath = (utils.queryParams.projectPath || '') .trim() .replace(/^\/*/, '') // Remove leading `/` .replace(/\/*$/, ''); // Remove trailing `/` workspaceParams.projectPath = projectPath; // Extract path param const path = (utils.queryParams.path || '') .trim() .replace(/^\/*/, '') // Remove leading `/` .replace(/\/*$/, '/'); // Add trailing `/` if (path !== '/') { workspaceParams.path = path; } const workspaceId = utils.makeWorkspaceId(workspaceParams); const workspace = store.getters['workspace/workspacesById'][workspaceId]; // See if we already have a token const sub = workspace ? workspace.sub : utils.queryParams.sub; let token = store.getters['data/gitlabTokensBySub'][sub]; if (!token) { const { applicationId } = await store.dispatch('modal/open', { type: 'gitlabAccount', forceServerUrl: serverUrl, }); token = await gitlabHelper.addAccount(serverUrl, applicationId, sub); } if (!workspace) { const projectId = await gitlabHelper.getProjectId(token, workspaceParams); const pathEntries = (path || '').split('/'); const projectPathEntries = (projectPath || '').split('/'); const name = pathEntries[pathEntries.length - 2] // path ends with `/` || projectPathEntries[projectPathEntries.length - 1]; store.dispatch('workspace/patchWorkspacesById', { [workspaceId]: { ...workspaceParams, projectId, id: workspaceId, sub: token.sub, name, }, }); } badgeSvc.addBadge('addGitlabWorkspace'); return store.getters['workspace/workspacesById'][workspaceId]; }, getChanges() { return gitlabHelper.getTree({ ...store.getters['workspace/currentWorkspace'], token: this.getToken(), }); }, prepareChanges(tree) { return gitWorkspaceSvc.makeChanges(tree.map(entry => ({ ...entry, sha: entry.id, }))); }, async saveWorkspaceItem({ item }) { const syncData = { id: store.getters.gitPathsByItemId[item.id], type: item.type, hash: item.hash, }; // Files and folders are not in git, only contents if (item.type === 'file' || item.type === 'folder') { return { syncData }; } // locations are stored as paths, so we upload an empty file const syncToken = store.getters['workspace/syncToken']; await gitlabHelper.uploadFile({ ...store.getters['workspace/currentWorkspace'], token: syncToken, path: getAbsolutePath(syncData), content: '', sha: gitWorkspaceSvc.shaByPath[syncData.id], }); // Return sync data to save return { syncData }; }, async removeWorkspaceItem({ syncData }) { if (gitWorkspaceSvc.shaByPath[syncData.id]) { const syncToken = store.getters['workspace/syncToken']; await gitlabHelper.removeFile({ ...store.getters['workspace/currentWorkspace'], token: syncToken, path: getAbsolutePath(syncData), sha: gitWorkspaceSvc.shaByPath[syncData.id], }); } }, async downloadWorkspaceContent({ token, contentId, contentSyncData, fileSyncData, }) { const { sha, data } = await gitlabHelper.downloadFile({ ...store.getters['workspace/currentWorkspace'], token, path: getAbsolutePath(fileSyncData), }); gitWorkspaceSvc.shaByPath[fileSyncData.id] = sha; const content = Provider.parseContent(data, contentId); return { content, contentSyncData: { ...contentSyncData, hash: content.hash, sha, }, }; }, async downloadWorkspaceData({ token, syncData }) { if (!syncData) { return {}; } const { sha, data } = await gitlabHelper.downloadFile({ ...store.getters['workspace/currentWorkspace'], token, path: getAbsolutePath(syncData), }); gitWorkspaceSvc.shaByPath[syncData.id] = sha; const item = JSON.parse(data); return { item, syncData: { ...syncData, hash: item.hash, sha, }, }; }, async uploadWorkspaceContent({ token, content, file }) { const path = store.getters.gitPathsByItemId[file.id]; const absolutePath = `${store.getters['workspace/currentWorkspace'].path || ''}${path}`; const sha = gitWorkspaceSvc.shaByPath[path]; await gitlabHelper.uploadFile({ ...store.getters['workspace/currentWorkspace'], token, path: absolutePath, content: Provider.serializeContent(content), sha, }); // Return new sync data return { contentSyncData: { id: store.getters.gitPathsByItemId[content.id], type: content.type, hash: content.hash, sha, }, fileSyncData: { id: path, type: 'file', hash: file.hash, }, }; }, async uploadWorkspaceData({ token, item }) { const path = store.getters.gitPathsByItemId[item.id]; const syncData = { id: path, type: item.type, hash: item.hash, }; const res = await gitlabHelper.uploadFile({ ...store.getters['workspace/currentWorkspace'], token, path: getAbsolutePath(syncData), content: JSON.stringify(item), sha: gitWorkspaceSvc.shaByPath[path], }); return { syncData: { ...syncData, sha: res.content.sha, }, }; }, async listFileRevisions({ token, fileSyncDataId }) { const { projectId, branch } = store.getters['workspace/currentWorkspace']; const entries = await gitlabHelper.getCommits({ token, projectId, sha: branch, path: getAbsolutePath({ id: fileSyncDataId }), }); return entries.map((entry) => { const email = entry.author_email || entry.committer_email; const sub = `${gitlabHelper.subPrefix}:${token.serverUrl}/${email}`; userSvc.addUserInfo({ id: sub, name: entry.author_name || entry.committer_name, imageUrl: '', // No way to get user's avatar url... }); const date = entry.authored_date || entry.committed_date || 1; return { id: entry.id, sub, created: date ? new Date(date).getTime() : 1, }; }); }, async loadFileRevision() { // Revisions are already loaded return false; }, async getFileRevisionContent({ token, contentId, fileSyncDataId, revisionId, }) { const { data } = await gitlabHelper.downloadFile({ ...store.getters['workspace/currentWorkspace'], token, branch: revisionId, path: getAbsolutePath({ id: fileSyncDataId }), }); return Provider.parseContent(data, contentId); }, }); ================================================ FILE: src/services/providers/googleDriveAppDataProvider.js ================================================ import store from '../../store'; import googleHelper from './helpers/googleHelper'; import Provider from './common/Provider'; import utils from '../utils'; let syncStartPageToken; export default new Provider({ id: 'googleDriveAppData', name: 'Google Drive app data', getToken() { return store.getters['workspace/syncToken']; }, getWorkspaceParams() { // No param as it's the main workspace return {}; }, getWorkspaceLocationUrl() { // No direct link to app data return null; }, getSyncDataUrl() { // No direct link to app data return null; }, getSyncDataDescription({ id }) { return id; }, async initWorkspace() { // Nothing much to do since the main workspace isn't necessarily synchronized // Return the main workspace return store.getters['workspace/workspacesById'].main; }, async getChanges() { const syncToken = store.getters['workspace/syncToken']; const startPageToken = store.getters['data/localSettings'].syncStartPageToken; const result = await googleHelper.getChanges(syncToken, startPageToken, true); const changes = result.changes.filter((change) => { if (change.file) { // Parse item from file name try { change.item = JSON.parse(change.file.name); } catch (e) { return false; } // Build sync data change.syncData = { id: change.fileId, itemId: change.item.id, type: change.item.type, hash: change.item.hash, }; } change.syncDataId = change.fileId; return true; }); syncStartPageToken = result.startPageToken; return changes; }, onChangesApplied() { store.dispatch('data/patchLocalSettings', { syncStartPageToken, }); }, async saveWorkspaceItem({ item, syncData, ifNotTooLate }) { const syncToken = store.getters['workspace/syncToken']; const file = await googleHelper.uploadAppDataFile({ token: syncToken, name: JSON.stringify(item), fileId: syncData && syncData.id, ifNotTooLate, }); // Build sync data to save return { syncData: { id: file.id, itemId: item.id, type: item.type, hash: item.hash, }, }; }, removeWorkspaceItem({ syncData, ifNotTooLate }) { const syncToken = store.getters['workspace/syncToken']; return googleHelper.removeAppDataFile(syncToken, syncData.id, ifNotTooLate); }, async downloadWorkspaceContent({ token, contentSyncData }) { const data = await googleHelper.downloadAppDataFile(token, contentSyncData.id); const content = utils.addItemHash(JSON.parse(data)); return { content, contentSyncData: { ...contentSyncData, hash: content.hash, }, }; }, async downloadWorkspaceData({ token, syncData }) { if (!syncData) { return {}; } const data = await googleHelper.downloadAppDataFile(token, syncData.id); const item = utils.addItemHash(JSON.parse(data)); return { item, syncData: { ...syncData, hash: item.hash, }, }; }, async uploadWorkspaceContent({ token, content, contentSyncData, ifNotTooLate, }) { const gdriveFile = await googleHelper.uploadAppDataFile({ token, name: JSON.stringify({ id: content.id, type: content.type, hash: content.hash, }), media: JSON.stringify(content), fileId: contentSyncData && contentSyncData.id, ifNotTooLate, }); // Return new sync data return { contentSyncData: { id: gdriveFile.id, itemId: content.id, type: content.type, hash: content.hash, }, }; }, async uploadWorkspaceData({ token, item, syncData, ifNotTooLate, }) { const file = await googleHelper.uploadAppDataFile({ token, name: JSON.stringify({ id: item.id, type: item.type, hash: item.hash, }), media: JSON.stringify(item), fileId: syncData && syncData.id, ifNotTooLate, }); // Return new sync data return { syncData: { id: file.id, itemId: item.id, type: item.type, hash: item.hash, }, }; }, async listFileRevisions({ token, contentSyncDataId }) { const revisions = await googleHelper.getAppDataFileRevisions(token, contentSyncDataId); return revisions.map(revision => ({ id: revision.id, sub: `${googleHelper.subPrefix}:${revision.lastModifyingUser.permissionId}`, created: new Date(revision.modifiedTime).getTime(), })); }, async loadFileRevision() { // Revisions are already loaded return false; }, async getFileRevisionContent({ token, contentSyncDataId, revisionId }) { const content = await googleHelper .downloadAppDataFileRevision(token, contentSyncDataId, revisionId); return JSON.parse(content); }, }); ================================================ FILE: src/services/providers/googleDriveProvider.js ================================================ import store from '../../store'; import googleHelper from './helpers/googleHelper'; import Provider from './common/Provider'; import utils from '../utils'; import workspaceSvc from '../workspaceSvc'; export default new Provider({ id: 'googleDrive', name: 'Google Drive', getToken({ sub }) { const token = store.getters['data/googleTokensBySub'][sub]; return token && token.isDrive ? token : null; }, getLocationUrl({ driveFileId }) { return `https://docs.google.com/file/d/${driveFileId}/edit`; }, getLocationDescription({ driveFileId }) { return driveFileId; }, async initAction() { const state = googleHelper.driveState || {}; if (state.userId) { // Try to find the token corresponding to the user ID let token = store.getters['data/googleTokensBySub'][state.userId]; // If not found or not enough permission, popup an OAuth2 window if (!token || !token.isDrive) { await store.dispatch('modal/open', { type: 'googleDriveAccount' }); token = await googleHelper.addDriveAccount( !store.getters['data/localSettings'].googleDriveRestrictedAccess, state.userId, ); } const openWorkspaceIfExists = (file) => { const folderId = file && file.appProperties && file.appProperties.folderId; if (folderId) { // See if we have the corresponding workspace const workspaceParams = { providerId: 'googleDriveWorkspace', folderId, }; const workspaceId = utils.makeWorkspaceId(workspaceParams); const workspace = store.getters['workspace/workspacesById'][workspaceId]; // If we have the workspace, open it by changing the current URL if (workspace) { utils.setQueryParams(workspaceParams); } } }; switch (state.action) { case 'create': default: // See if folder is part of a workspace we can open try { const folder = await googleHelper.getFile(token, state.folderId); folder.appProperties = folder.appProperties || {}; googleHelper.driveActionFolder = folder; openWorkspaceIfExists(folder); } catch (err) { if (!err || err.status !== 404) { throw err; } // We received an HTTP 404 meaning we have no permission to read the folder googleHelper.driveActionFolder = { id: state.folderId }; } break; case 'open': { await utils.awaitSequence(state.ids || [], async (id) => { const file = await googleHelper.getFile(token, id); file.appProperties = file.appProperties || {}; googleHelper.driveActionFiles.push(file); }); // Check if first file is part of a workspace openWorkspaceIfExists(googleHelper.driveActionFiles[0]); } } } }, async performAction() { const state = googleHelper.driveState || {}; const token = store.getters['data/googleTokensBySub'][state.userId]; switch (token && state.action) { case 'create': { const file = await workspaceSvc.createFile({}, true); store.commit('file/setCurrentId', file.id); // Return a new syncLocation return this.makeLocation(token, null, googleHelper.driveActionFolder.id); } case 'open': store.dispatch( 'queue/enqueue', () => this.openFiles(token, googleHelper.driveActionFiles), ); return null; default: return null; } }, async downloadContent(token, syncLocation) { const content = await googleHelper.downloadFile(token, syncLocation.driveFileId); return Provider.parseContent(content, `${syncLocation.fileId}/content`); }, async uploadContent(token, content, syncLocation, ifNotTooLate) { const file = store.state.file.itemsById[syncLocation.fileId]; const name = utils.sanitizeName(file && file.name); const parents = []; if (syncLocation.driveParentId) { parents.push(syncLocation.driveParentId); } const driveFile = await googleHelper.uploadFile({ token, name, parents, media: Provider.serializeContent(content), fileId: syncLocation.driveFileId, ifNotTooLate, }); return { ...syncLocation, driveFileId: driveFile.id, }; }, async publish(token, html, metadata, publishLocation) { const driveFile = await googleHelper.uploadFile({ token, name: metadata.title, parents: [], media: html, mediaType: publishLocation.templateId ? 'text/html' : undefined, fileId: publishLocation.driveFileId, }); return { ...publishLocation, driveFileId: driveFile.id, }; }, async openFiles(token, driveFiles) { return utils.awaitSequence(driveFiles, async (driveFile) => { // Check if the file exists and open it if (!Provider.openFileWithLocation({ providerId: this.id, driveFileId: driveFile.id, })) { // Download content from Google Drive const syncLocation = { driveFileId: driveFile.id, providerId: this.id, sub: token.sub, }; let content; try { content = await this.downloadContent(token, syncLocation); } catch (e) { store.dispatch('notification/error', `Could not open file ${driveFile.id}.`); return; } // Create the file const item = await workspaceSvc.createFile({ name: driveFile.name, parentId: store.getters['file/current'].parentId, text: content.text, properties: content.properties, discussions: content.discussions, comments: content.comments, }, true); store.commit('file/setCurrentId', item.id); workspaceSvc.addSyncLocation({ ...syncLocation, fileId: item.id, }); store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from Google Drive.`); } }); }, makeLocation(token, fileId, folderId) { const location = { providerId: this.id, sub: token.sub, }; if (fileId) { location.driveFileId = fileId; } if (folderId) { location.driveParentId = folderId; } return location; }, async listFileRevisions({ token, syncLocation }) { const revisions = await googleHelper.getFileRevisions(token, syncLocation.driveFileId); return revisions.map(revision => ({ id: revision.id, sub: `${googleHelper.subPrefix}:${revision.lastModifyingUser.permissionId}`, created: new Date(revision.modifiedTime).getTime(), })); }, async loadFileRevision() { // Revision are already loaded return false; }, async getFileRevisionContent({ token, contentId, syncLocation, revisionId, }) { const content = await googleHelper .downloadFileRevision(token, syncLocation.driveFileId, revisionId); return Provider.parseContent(content, contentId); }, }); ================================================ FILE: src/services/providers/googleDriveWorkspaceProvider.js ================================================ import store from '../../store'; import googleHelper from './helpers/googleHelper'; import Provider from './common/Provider'; import utils from '../utils'; import workspaceSvc from '../workspaceSvc'; import badgeSvc from '../badgeSvc'; let fileIdToOpen; let syncStartPageToken; export default new Provider({ id: 'googleDriveWorkspace', name: 'Google Drive', getToken() { return store.getters['workspace/syncToken']; }, getWorkspaceParams({ folderId }) { return { providerId: this.id, folderId, }; }, getWorkspaceLocationUrl({ folderId }) { return `https://docs.google.com/folder/d/${folderId}`; }, getSyncDataUrl({ id }) { return `https://docs.google.com/file/d/${id}/edit`; }, getSyncDataDescription({ id }) { return id; }, async initWorkspace() { const makeWorkspaceId = folderId => folderId && utils.makeWorkspaceId(this.getWorkspaceParams({ folderId })); const getWorkspace = folderId => store.getters['workspace/workspacesById'][makeWorkspaceId(folderId)]; const initFolder = async (token, folder) => { const appProperties = { folderId: folder.id, dataFolderId: folder.appProperties.dataFolderId, trashFolderId: folder.appProperties.trashFolderId, }; // Make sure data folder exists if (!appProperties.dataFolderId) { const dataFolder = await googleHelper.uploadFile({ token, name: '.stackedit-data', parents: [folder.id], appProperties: { folderId: folder.id }, mediaType: googleHelper.folderMimeType, }); appProperties.dataFolderId = dataFolder.id; } // Make sure trash folder exists if (!appProperties.trashFolderId) { const trashFolder = await googleHelper.uploadFile({ token, name: '.stackedit-trash', parents: [folder.id], appProperties: { folderId: folder.id }, mediaType: googleHelper.folderMimeType, }); appProperties.trashFolderId = trashFolder.id; } // Update workspace if some properties are missing if (appProperties.folderId !== folder.appProperties.folderId || appProperties.dataFolderId !== folder.appProperties.dataFolderId || appProperties.trashFolderId !== folder.appProperties.trashFolderId ) { await googleHelper.uploadFile({ token, appProperties, mediaType: googleHelper.folderMimeType, fileId: folder.id, }); } // Update workspace in the store const workspaceId = makeWorkspaceId(folder.id); store.dispatch('workspace/patchWorkspacesById', { [workspaceId]: { id: workspaceId, sub: token.sub, name: folder.name, providerId: this.id, folderId: folder.id, teamDriveId: folder.teamDriveId, dataFolderId: appProperties.dataFolderId, trashFolderId: appProperties.trashFolderId, }, }); }; // Token sub is in the workspace or in the url if workspace is about to be created const { sub } = getWorkspace(utils.queryParams.folderId) || utils.queryParams; // See if we already have a token let token = store.getters['data/googleTokensBySub'][sub]; // If no token has been found, popup an authorize window and get one if (!token || !token.isDrive || !token.driveFullAccess) { await store.dispatch('modal/open', 'workspaceGoogleRedirection'); token = await googleHelper.addDriveAccount(true, utils.queryParams.sub); } let { folderId } = utils.queryParams; // If no folderId is provided, create one if (!folderId) { const folder = await googleHelper.uploadFile({ token, name: 'StackEdit workspace', parents: [], mediaType: googleHelper.folderMimeType, }); await initFolder(token, { ...folder, appProperties: {}, }); folderId = folder.id; } // Init workspace if (!getWorkspace(folderId)) { let folder; try { folder = await googleHelper.getFile(token, folderId); } catch (err) { throw new Error(`Folder ${folderId} is not accessible. Make sure you have the right permissions.`); } folder.appProperties = folder.appProperties || {}; const folderIdProperty = folder.appProperties.folderId; if (folderIdProperty && folderIdProperty !== folderId) { throw new Error(`Folder ${folderId} is part of another workspace.`); } await initFolder(token, folder); } badgeSvc.addBadge('addGoogleDriveWorkspace'); return getWorkspace(folderId); }, async performAction() { const state = googleHelper.driveState || {}; const token = this.getToken(); switch (token && state.action) { case 'create': { const driveFolder = googleHelper.driveActionFolder; let syncData = store.getters['data/syncDataById'][driveFolder.id]; if (!syncData && driveFolder.appProperties.id) { // Create folder if not already synced store.commit('folder/setItem', { id: driveFolder.appProperties.id, name: driveFolder.name, }); const item = store.state.folder.itemsById[driveFolder.appProperties.id]; syncData = { id: driveFolder.id, itemId: item.id, type: item.type, hash: item.hash, }; store.dispatch('data/patchSyncDataById', { [syncData.id]: syncData, }); } const file = await workspaceSvc.createFile({ parentId: syncData && syncData.itemId, }, true); store.commit('file/setCurrentId', file.id); // File will be created on next workspace sync break; } case 'open': { // open first file only const firstFile = googleHelper.driveActionFiles[0]; const syncData = store.getters['data/syncDataById'][firstFile.id]; if (!syncData) { fileIdToOpen = firstFile.id; } else { store.commit('file/setCurrentId', syncData.itemId); } break; } default: } }, async getChanges() { const workspace = store.getters['workspace/currentWorkspace']; const syncToken = store.getters['workspace/syncToken']; const lastStartPageToken = store.getters['data/localSettings'].syncStartPageToken; const { changes, startPageToken } = await googleHelper .getChanges(syncToken, lastStartPageToken, false, workspace.teamDriveId); syncStartPageToken = startPageToken; return changes; }, prepareChanges(changes) { // Collect possible parent IDs const parentIds = {}; Object.entries(store.getters['data/syncDataByItemId']).forEach(([id, syncData]) => { parentIds[syncData.id] = id; }); changes.forEach((change) => { const { id } = (change.file || {}).appProperties || {}; if (id) { parentIds[change.fileId] = id; } }); // Collect changes const workspace = store.getters['workspace/currentWorkspace']; const result = []; changes.forEach((change) => { // Ignore changes on StackEdit own folders if (change.fileId === workspace.folderId || change.fileId === workspace.dataFolderId || change.fileId === workspace.trashFolderId ) { return; } let contentChange; if (change.file) { // Ignore changes in files that are not in the workspace const { appProperties } = change.file; if (!appProperties || appProperties.folderId !== workspace.folderId ) { return; } // If change is on a data item if (change.file.parents[0] === workspace.dataFolderId) { // Data item has a JSON filename try { change.item = JSON.parse(change.file.name); } catch (e) { return; } } else { // Change on a file or folder const type = change.file.mimeType === googleHelper.folderMimeType ? 'folder' : 'file'; const item = { id: appProperties.id, type, name: change.file.name, parentId: null, }; // Fill parentId if (change.file.parents.some(parentId => parentId === workspace.trashFolderId)) { item.parentId = 'trash'; } else { change.file.parents.some((parentId) => { if (!parentIds[parentId]) { return false; } item.parentId = parentIds[parentId]; return true; }); } change.item = utils.addItemHash(item); if (type === 'file') { // create a fake change as a file content change const id = `${appProperties.id}/content`; const syncDataId = `${change.fileId}/content`; contentChange = { item: { id, type: 'content', // Need a truthy value to force saving sync data hash: 1, }, syncData: { id: syncDataId, itemId: id, type: 'content', // Need a truthy value to force downloading the content hash: 1, }, syncDataId, }; } } // Build sync data change.syncData = { id: change.fileId, parentIds: change.file.parents, itemId: change.item.id, type: change.item.type, hash: change.item.hash, }; } else { // Item was removed const syncData = store.getters['data/syncDataById'][change.fileId]; if (syncData && syncData.type === 'file') { // create a fake change as a file content change contentChange = { syncDataId: `${change.fileId}/content`, }; } } // Push change change.syncDataId = change.fileId; result.push(change); if (contentChange) { result.push(contentChange); } }); return result; }, onChangesApplied() { store.dispatch('data/patchLocalSettings', { syncStartPageToken, }); }, async saveWorkspaceItem({ item, syncData, ifNotTooLate }) { const workspace = store.getters['workspace/currentWorkspace']; const syncToken = store.getters['workspace/syncToken']; let file; if (item.type !== 'file' && item.type !== 'folder') { // For sync/publish locations, store item as filename file = await googleHelper.uploadFile({ token: syncToken, name: JSON.stringify(item), parents: [workspace.dataFolderId], appProperties: { folderId: workspace.folderId, }, fileId: syncData && syncData.id, oldParents: syncData && syncData.parentIds, ifNotTooLate, }); } else { // For type `file` or `folder` const parentSyncData = store.getters['data/syncDataByItemId'][item.parentId]; let parentId; if (item.parentId === 'trash') { parentId = workspace.trashFolderId; } else if (parentSyncData) { parentId = parentSyncData.id; } else { parentId = workspace.folderId; } file = await googleHelper.uploadFile({ token: syncToken, name: item.name, parents: [parentId], appProperties: { id: item.id, folderId: workspace.folderId, }, mediaType: item.type === 'folder' ? googleHelper.folderMimeType : undefined, fileId: syncData && syncData.id, oldParents: syncData && syncData.parentIds, ifNotTooLate, }); } // Build sync data to save return { syncData: { id: file.id, parentIds: file.parents, itemId: item.id, type: item.type, hash: item.hash, }, }; }, async removeWorkspaceItem({ syncData, ifNotTooLate }) { // Ignore content deletion if (syncData.type !== 'content') { const syncToken = store.getters['workspace/syncToken']; await googleHelper.removeFile(syncToken, syncData.id, ifNotTooLate); } }, async downloadWorkspaceContent({ token, contentSyncData, fileSyncData }) { const data = await googleHelper.downloadFile(token, fileSyncData.id); const content = Provider.parseContent(data, contentSyncData.itemId); // Open the file requested by action if it wasn't synced yet if (fileIdToOpen && fileIdToOpen === fileSyncData.id) { fileIdToOpen = null; // Open the file once downloaded content has been stored setTimeout(() => { store.commit('file/setCurrentId', fileSyncData.itemId); }, 10); } return { content, contentSyncData: { ...contentSyncData, hash: content.hash, }, }; }, async downloadWorkspaceData({ token, syncData }) { if (!syncData) { return {}; } const content = await googleHelper.downloadFile(token, syncData.id); const item = JSON.parse(content); return { item, syncData: { ...syncData, hash: item.hash, }, }; }, async uploadWorkspaceContent({ token, content, file, fileSyncData, ifNotTooLate, }) { let gdriveFile; let newFileSyncData; if (fileSyncData) { // Only update file media gdriveFile = await googleHelper.uploadFile({ token, media: Provider.serializeContent(content), fileId: fileSyncData.id, ifNotTooLate, }); } else { // Create file with media const workspace = store.getters['workspace/currentWorkspace']; const parentSyncData = store.getters['data/syncDataByItemId'][file.parentId]; gdriveFile = await googleHelper.uploadFile({ token, name: file.name, parents: [parentSyncData ? parentSyncData.id : workspace.folderId], appProperties: { id: file.id, folderId: workspace.folderId, }, media: Provider.serializeContent(content), ifNotTooLate, }); // Create file sync data newFileSyncData = { id: gdriveFile.id, parentIds: gdriveFile.parents, itemId: file.id, type: file.type, hash: file.hash, }; } // Return new sync data return { contentSyncData: { id: `${gdriveFile.id}/content`, itemId: content.id, type: content.type, hash: content.hash, }, fileSyncData: newFileSyncData, }; }, async uploadWorkspaceData({ token, item, syncData, ifNotTooLate, }) { const workspace = store.getters['workspace/currentWorkspace']; const file = await googleHelper.uploadFile({ token, name: JSON.stringify({ id: item.id, type: item.type, hash: item.hash, }), parents: [workspace.dataFolderId], appProperties: { folderId: workspace.folderId, }, media: JSON.stringify(item), mediaType: 'application/json', fileId: syncData && syncData.id, oldParents: syncData && syncData.parentIds, ifNotTooLate, }); // Return new sync data return { syncData: { id: file.id, parentIds: file.parents, itemId: item.id, type: item.type, hash: item.hash, }, }; }, async listFileRevisions({ token, fileSyncDataId }) { const revisions = await googleHelper.getFileRevisions(token, fileSyncDataId); return revisions.map(revision => ({ id: revision.id, sub: `${googleHelper.subPrefix}:${revision.lastModifyingUser.permissionId}`, created: new Date(revision.modifiedTime).getTime(), })); }, async loadFileRevision() { // Revision are already loaded return false; }, async getFileRevisionContent({ token, contentId, fileSyncDataId, revisionId, }) { const content = await googleHelper.downloadFileRevision(token, fileSyncDataId, revisionId); return Provider.parseContent(content, contentId); }, }); ================================================ FILE: src/services/providers/helpers/couchdbHelper.js ================================================ import networkSvc from '../../networkSvc'; import utils from '../../utils'; import store from '../../../store'; import userSvc from '../../userSvc'; const request = async (token, options = {}) => { const baseUrl = `${token.dbUrl}/`; const getLastToken = () => store.getters['data/couchdbTokensBySub'][token.sub]; const assertUnauthorized = (err) => { if (err.status !== 401) { throw err; } }; const onUnauthorized = async () => { try { const { name, password } = getLastToken(); await networkSvc.request({ method: 'POST', url: utils.resolveUrl(baseUrl, '../_session'), withCredentials: true, body: { name, password, }, }); } catch (err) { assertUnauthorized(err); await store.dispatch('modal/open', { type: 'couchdbCredentials', token: getLastToken(), }); await onUnauthorized(); } }; const config = { ...options, headers: { Accept: 'application/json', ...options.headers || {}, }, url: utils.resolveUrl(baseUrl, options.path || '.'), withCredentials: true, }; try { let res; try { res = await networkSvc.request(config); } catch (err) { assertUnauthorized(err); await onUnauthorized(); res = await networkSvc.request(config); } return res.body; } catch (err) { if (err.status === 409) { throw new Error('TOO_LATE'); } throw err; } }; export default { /** * http://docs.couchdb.org/en/2.1.1/api/database/common.html#db */ getDb(token) { return request(token); }, /** * http://docs.couchdb.org/en/2.1.1/api/database/changes.html#db-changes */ async getChanges(token, lastSeq) { const result = { changes: [], lastSeq, }; const getPage = async () => { const body = await request(token, { method: 'GET', path: '_changes', params: { since: result.lastSeq || 0, include_docs: true, limit: 1000, }, }); result.changes = [...result.changes, ...body.results]; result.lastSeq = body.last_seq; if (body.pending) { return getPage(); } return result; }; return getPage(); }, /** * http://docs.couchdb.org/en/2.1.1/api/database/common.html#post--db * http://docs.couchdb.org/en/2.1.1/api/document/common.html#put--db-docid */ async uploadDocument({ token, item, data = null, dataType = null, documentId = null, rev = null, }) { const options = { method: 'POST', body: { item, time: Date.now() }, }; const userId = userSvc.getCurrentUserId(); if (userId) { options.body.sub = userId; } if (documentId) { options.method = 'PUT'; options.path = documentId; options.body._rev = rev; // eslint-disable-line no-underscore-dangle } if (data) { options.body._attachments = { // eslint-disable-line no-underscore-dangle data: { content_type: dataType, data: utils.encodeBase64(data), }, }; } return request(token, options); }, /** * http://docs.couchdb.org/en/2.1.1/api/document/common.html#delete--db-docid */ async removeDocument(token, documentId, rev) { if (!documentId) { // Prevent from deleting the whole database throw new Error('Missing document ID'); } return request(token, { method: 'DELETE', path: documentId, params: { rev }, }); }, /** * http://docs.couchdb.org/en/2.1.1/api/document/common.html#get--db-docid */ async retrieveDocument(token, documentId, rev) { return request(token, { path: documentId, params: { rev }, }); }, /** * http://docs.couchdb.org/en/2.1.1/api/document/common.html#get--db-docid */ async retrieveDocumentWithAttachments(token, documentId, rev) { const body = await request(token, { path: documentId, params: { attachments: true, rev }, }); body.attachments = {}; // eslint-disable-next-line no-underscore-dangle Object.entries(body._attachments).forEach(([name, attachment]) => { body.attachments[name] = utils.decodeBase64(attachment.data); }); return body; }, /** * http://docs.couchdb.org/en/2.1.1/api/document/common.html#get--db-docid */ async retrieveDocumentWithRevisions(token, documentId) { return request(token, { path: documentId, params: { revs_info: true, }, }); }, }; ================================================ FILE: src/services/providers/helpers/dropboxHelper.js ================================================ import networkSvc from '../../networkSvc'; import userSvc from '../../userSvc'; import store from '../../../store'; import badgeSvc from '../../badgeSvc'; const getAppKey = (fullAccess) => { if (fullAccess) { return store.getters['data/serverConf'].dropboxAppKeyFull; } return store.getters['data/serverConf'].dropboxAppKey; }; const httpHeaderSafeJson = args => args && JSON.stringify(args) .replace(/[\u007f-\uffff]/g, c => `\\u${`000${c.charCodeAt(0).toString(16)}`.slice(-4)}`); const request = ({ accessToken }, options, args) => networkSvc.request({ ...options, headers: { ...options.headers || {}, 'Content-Type': options.body && (typeof options.body === 'string' ? 'application/octet-stream' : 'application/json; charset=utf-8'), 'Dropbox-API-Arg': httpHeaderSafeJson(args), Authorization: `Bearer ${accessToken}`, }, }); /** * https://www.dropbox.com/developers/documentation/http/documentation#users-get_account */ const subPrefix = 'db'; userSvc.setInfoResolver('dropbox', subPrefix, async (sub) => { const dropboxToken = Object.values(store.getters['data/dropboxTokensBySub'])[0]; try { const { body } = await request(dropboxToken, { method: 'POST', url: 'https://api.dropboxapi.com/2/users/get_account', body: { account_id: sub, }, }); return { id: `${subPrefix}:${body.account_id}`, name: body.name.display_name, imageUrl: body.profile_photo_url || '', }; } catch (err) { if (!dropboxToken || err.status !== 404) { throw new Error('RETRY'); } throw err; } }); export default { subPrefix, /** * https://www.dropbox.com/developers/documentation/http/documentation#oauth2-authorize * https://www.dropbox.com/developers/documentation/http/documentation#users-get_current_account */ async startOauth2(fullAccess, sub = null, silent = false) { // Get an OAuth2 code const { accessToken } = await networkSvc.startOauth2( 'https://www.dropbox.com/oauth2/authorize', { client_id: getAppKey(fullAccess), response_type: 'token', }, silent, ); // Call the user info endpoint const { body } = await request({ accessToken }, { method: 'POST', url: 'https://api.dropboxapi.com/2/users/get_current_account', }); userSvc.addUserInfo({ id: `${subPrefix}:${body.account_id}`, name: body.name.display_name, imageUrl: body.profile_photo_url || '', }); // Check the returned sub consistency if (sub && `${body.account_id}` !== sub) { throw new Error('Dropbox account ID not expected.'); } // Build token object including scopes and sub const token = { accessToken, name: body.name.display_name, sub: `${body.account_id}`, fullAccess, }; // Add token to dropbox tokens store.dispatch('data/addDropboxToken', token); return token; }, async addAccount(fullAccess = false) { const token = await this.startOauth2(fullAccess); badgeSvc.addBadge('addDropboxAccount'); return token; }, /** * https://www.dropbox.com/developers/documentation/http/documentation#files-upload */ async uploadFile({ token, path, content, fileId, }) { return (await request(token, { method: 'POST', url: 'https://content.dropboxapi.com/2/files/upload', body: content, }, { path: fileId || path, mode: 'overwrite', })).body; }, /** * https://www.dropbox.com/developers/documentation/http/documentation#files-download */ async downloadFile({ token, path, fileId, }) { const res = await request(token, { method: 'POST', url: 'https://content.dropboxapi.com/2/files/download', raw: true, }, { path: fileId || path, }); return { id: JSON.parse(res.headers['dropbox-api-result']).id, content: res.body, }; }, /** * https://www.dropbox.com/developers/documentation/http/documentation#list-revisions */ async listRevisions({ token, path, fileId, }) { const res = await request(token, { method: 'POST', url: 'https://api.dropboxapi.com/2/files/list_revisions', body: fileId ? { path: fileId, mode: 'id', limit: 100, } : { path, limit: 100, }, }); return res.body.entries; }, /** * https://www.dropbox.com/developers/chooser */ async openChooser(token) { if (!window.Dropbox) { await networkSvc.loadScript('https://www.dropbox.com/static/api/2/dropins.js'); } return new Promise((resolve) => { window.Dropbox.appKey = getAppKey(token.fullAccess); window.Dropbox.choose({ multiselect: true, linkType: 'direct', success: files => resolve(files.map((file) => { const path = file.link.replace(/.*\/view\/[^/]*/, ''); return decodeURI(path); })), cancel: () => resolve([]), }); }); }, }; ================================================ FILE: src/services/providers/helpers/githubHelper.js ================================================ import utils from '../../utils'; import networkSvc from '../../networkSvc'; import store from '../../../store'; import userSvc from '../../userSvc'; import badgeSvc from '../../badgeSvc'; const getScopes = token => [token.repoFullAccess ? 'repo' : 'public_repo', 'gist']; const request = (token, options) => networkSvc.request({ ...options, headers: { ...options.headers || {}, Authorization: `token ${token.accessToken}`, }, params: { ...options.params || {}, t: Date.now(), // Prevent from caching }, }); const repoRequest = (token, owner, repo, options) => request(token, { ...options, url: `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${options.url}`, }) .then(res => res.body); const getCommitMessage = (name, path) => { const message = store.getters['data/computedSettings'].git[name]; return message.replace(/{{path}}/g, path); }; /** * Getting a user from its userId is not feasible with API v3. * Using an undocumented endpoint... */ const subPrefix = 'gh'; userSvc.setInfoResolver('github', subPrefix, async (sub) => { try { const user = (await networkSvc.request({ url: `https://api.github.com/user/${sub}`, params: { t: Date.now(), // Prevent from caching }, })).body; return { id: `${subPrefix}:${user.id}`, name: user.login, imageUrl: user.avatar_url || '', }; } catch (err) { if (err.status !== 404) { throw new Error('RETRY'); } throw err; } }); export default { subPrefix, /** * https://developer.github.com/apps/building-oauth-apps/authorization-options-for-oauth-apps/ */ async startOauth2(scopes, sub = null, silent = false) { const clientId = store.getters['data/serverConf'].githubClientId; // Get an OAuth2 code const { code } = await networkSvc.startOauth2( 'https://github.com/login/oauth/authorize', { client_id: clientId, scope: scopes.join(' '), }, silent, ); // Exchange code with token const accessToken = (await networkSvc.request({ method: 'GET', url: 'oauth2/githubToken', params: { clientId, code, }, })).body; // Call the user info endpoint const user = (await networkSvc.request({ method: 'GET', url: 'https://api.github.com/user', headers: { Authorization: `token ${accessToken}`, }, })).body; userSvc.addUserInfo({ id: `${subPrefix}:${user.id}`, name: user.login, imageUrl: user.avatar_url || '', }); // Check the returned sub consistency if (sub && `${user.id}` !== sub) { throw new Error('GitHub account ID not expected.'); } // Build token object including scopes and sub const token = { scopes, accessToken, name: user.login, sub: `${user.id}`, repoFullAccess: scopes.includes('repo'), }; // Add token to github tokens store.dispatch('data/addGithubToken', token); return token; }, async addAccount(repoFullAccess = false) { const token = await this.startOauth2(getScopes({ repoFullAccess })); badgeSvc.addBadge('addGitHubAccount'); return token; }, /** * https://developer.github.com/v3/repos/commits/#get-a-single-commit * https://developer.github.com/v3/git/trees/#get-a-tree */ async getTree({ token, owner, repo, branch, }) { const { commit } = await repoRequest(token, owner, repo, { url: `commits/${encodeURIComponent(branch)}`, }); const { tree, truncated } = await repoRequest(token, owner, repo, { url: `git/trees/${encodeURIComponent(commit.tree.sha)}?recursive=1`, }); if (truncated) { throw new Error('Git tree too big. Please remove some files in the repository.'); } return tree; }, /** * https://developer.github.com/v3/repos/commits/#list-commits-on-a-repository */ async getCommits({ token, owner, repo, sha, path, }) { return repoRequest(token, owner, repo, { url: 'commits', params: { sha, path }, }); }, /** * https://developer.github.com/v3/repos/contents/#create-a-file * https://developer.github.com/v3/repos/contents/#update-a-file */ async uploadFile({ token, owner, repo, branch, path, content, sha, }) { return repoRequest(token, owner, repo, { method: 'PUT', url: `contents/${encodeURIComponent(path)}`, body: { message: getCommitMessage(sha ? 'updateFileMessage' : 'createFileMessage', path), content: utils.encodeBase64(content), sha, branch, }, }); }, /** * https://developer.github.com/v3/repos/contents/#delete-a-file */ async removeFile({ token, owner, repo, branch, path, sha, }) { return repoRequest(token, owner, repo, { method: 'DELETE', url: `contents/${encodeURIComponent(path)}`, body: { message: getCommitMessage('deleteFileMessage', path), sha, branch, }, }); }, /** * https://developer.github.com/v3/repos/contents/#get-contents */ async downloadFile({ token, owner, repo, branch, path, }) { const { sha, content } = await repoRequest(token, owner, repo, { url: `contents/${encodeURIComponent(path)}`, params: { ref: branch }, }); return { sha, data: utils.decodeBase64(content), }; }, /** * https://developer.github.com/v3/gists/#create-a-gist * https://developer.github.com/v3/gists/#edit-a-gist */ async uploadGist({ token, description, filename, content, isPublic, gistId, }) { const { body } = await request(token, gistId ? { method: 'PATCH', url: `https://api.github.com/gists/${gistId}`, body: { description, files: { [filename]: { content, }, }, }, } : { method: 'POST', url: 'https://api.github.com/gists', body: { description, files: { [filename]: { content, }, }, public: isPublic, }, }); return body; }, /** * https://developer.github.com/v3/gists/#get-a-single-gist */ async downloadGist({ token, gistId, filename, }) { const result = (await request(token, { url: `https://api.github.com/gists/${gistId}`, })).body.files[filename]; if (!result) { throw new Error('Gist file not found.'); } return result.content; }, /** * https://developer.github.com/v3/gists/#list-gist-commits */ async getGistCommits({ token, gistId, }) { const { body } = await request(token, { url: `https://api.github.com/gists/${gistId}/commits`, }); return body; }, /** * https://developer.github.com/v3/gists/#get-a-specific-revision-of-a-gist */ async downloadGistRevision({ token, gistId, filename, sha, }) { const result = (await request(token, { url: `https://api.github.com/gists/${gistId}/${sha}`, })).body.files[filename]; if (!result) { throw new Error('Gist file not found.'); } return result.content; }, }; ================================================ FILE: src/services/providers/helpers/gitlabHelper.js ================================================ import utils from '../../utils'; import networkSvc from '../../networkSvc'; import store from '../../../store'; import userSvc from '../../userSvc'; import badgeSvc from '../../badgeSvc'; const request = ({ accessToken, serverUrl }, options) => networkSvc.request({ ...options, url: `${serverUrl}/api/v4/${options.url}`, headers: { ...options.headers || {}, Authorization: `Bearer ${accessToken}`, }, }) .then(res => res.body); const getCommitMessage = (name, path) => { const message = store.getters['data/computedSettings'].git[name]; return message.replace(/{{path}}/g, path); }; /** * https://docs.gitlab.com/ee/api/users.html#for-user */ const subPrefix = 'gl'; userSvc.setInfoResolver('gitlab', subPrefix, async (sub) => { try { const [, serverUrl, id] = sub.match(/^(.+)\/([^/]+)$/); const user = (await networkSvc.request({ url: `${serverUrl}/api/v4/users/${id}`, })).body; const uniqueSub = `${serverUrl}/${user.id}`; return { id: `${subPrefix}:${uniqueSub}`, name: user.username, imageUrl: user.avatar_url || '', }; } catch (err) { if (err.status !== 404) { throw new Error('RETRY'); } throw err; } }); export default { subPrefix, /** * https://docs.gitlab.com/ee/api/oauth2.html */ async startOauth2(serverUrl, applicationId, sub = null, silent = false) { // Get an OAuth2 code const { accessToken } = await networkSvc.startOauth2( `${serverUrl}/oauth/authorize`, { client_id: applicationId, response_type: 'token', scope: 'api', }, silent, ); // Call the user info endpoint const user = await request({ accessToken, serverUrl }, { url: 'user', }); const uniqueSub = `${serverUrl}/${user.id}`; userSvc.addUserInfo({ id: `${subPrefix}:${uniqueSub}`, name: user.username, imageUrl: user.avatar_url || '', }); // Check the returned sub consistency if (sub && uniqueSub !== sub) { throw new Error('GitLab account ID not expected.'); } // Build token object including scopes and sub const token = { accessToken, name: user.username, serverUrl, sub: uniqueSub, }; // Add token to gitlab tokens store.dispatch('data/addGitlabToken', token); return token; }, async addAccount(serverUrl, applicationId, sub = null) { const token = await this.startOauth2(serverUrl, applicationId, sub); badgeSvc.addBadge('addGitLabAccount'); return token; }, /** * https://docs.gitlab.com/ee/api/projects.html#get-single-project */ async getProjectId(token, { projectPath, projectId }) { if (projectId) { return projectId; } const project = await request(token, { url: `projects/${encodeURIComponent(projectPath)}`, }); return project.id; }, /** * https://docs.gitlab.com/ee/api/repositories.html#list-repository-tree */ async getTree({ token, projectId, branch, }) { return request(token, { url: `projects/${encodeURIComponent(projectId)}/repository/tree`, params: { ref: branch, recursive: true, per_page: 9999, }, }); }, /** * https://docs.gitlab.com/ee/api/commits.html#list-repository-commits */ async getCommits({ token, projectId, branch, path, }) { return request(token, { url: `projects/${encodeURIComponent(projectId)}/repository/commits`, params: { ref_name: branch, path, }, }); }, /** * https://docs.gitlab.com/ee/api/repository_files.html#create-new-file-in-repository * https://docs.gitlab.com/ee/api/repository_files.html#update-existing-file-in-repository */ async uploadFile({ token, projectId, branch, path, content, sha, }) { return request(token, { method: sha ? 'PUT' : 'POST', url: `projects/${encodeURIComponent(projectId)}/repository/files/${encodeURIComponent(path)}`, body: { commit_message: getCommitMessage(sha ? 'updateFileMessage' : 'createFileMessage', path), content, last_commit_id: sha, branch, }, }); }, /** * https://docs.gitlab.com/ee/api/repository_files.html#delete-existing-file-in-repository */ async removeFile({ token, projectId, branch, path, sha, }) { return request(token, { method: 'DELETE', url: `projects/${encodeURIComponent(projectId)}/repository/files/${encodeURIComponent(path)}`, body: { commit_message: getCommitMessage('deleteFileMessage', path), last_commit_id: sha, branch, }, }); }, /** * https://docs.gitlab.com/ee/api/repository_files.html#get-file-from-repository */ async downloadFile({ token, projectId, branch, path, }) { const res = await request(token, { url: `projects/${encodeURIComponent(projectId)}/repository/files/${encodeURIComponent(path)}`, params: { ref: branch }, }); return { sha: res.last_commit_id, data: utils.decodeBase64(res.content), }; }, }; ================================================ FILE: src/services/providers/helpers/googleHelper.js ================================================ import utils from '../../utils'; import networkSvc from '../../networkSvc'; import store from '../../../store'; import userSvc from '../../userSvc'; import badgeSvc from '../../badgeSvc'; const appsDomain = null; const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (tokens expire after 1h) const driveAppDataScopes = ['https://www.googleapis.com/auth/drive.appdata']; const getDriveScopes = token => [token.driveFullAccess ? 'https://www.googleapis.com/auth/drive' : 'https://www.googleapis.com/auth/drive.file', 'https://www.googleapis.com/auth/drive.install']; const bloggerScopes = ['https://www.googleapis.com/auth/blogger']; const photosScopes = ['https://www.googleapis.com/auth/photos']; const checkIdToken = (idToken) => { try { const token = idToken.split('.'); const payload = JSON.parse(utils.decodeBase64(token[1])); const clientId = store.getters['data/serverConf'].googleClientId; return payload.aud === clientId && Date.now() + tokenExpirationMargin < payload.exp * 1000; } catch (e) { return false; } }; let driveState; if (utils.queryParams.providerId === 'googleDrive') { try { driveState = JSON.parse(utils.queryParams.state); } catch (e) { // Ignore } } /** * https://developers.google.com/people/api/rest/v1/people/get */ const getUser = async (sub, token) => { const apiKey = store.getters['data/serverConf'].googleApiKey; const url = `https://people.googleapis.com/v1/people/${sub}?personFields=names,photos&key=${apiKey}`; const { body } = await networkSvc.request(sub === 'me' && token ? { method: 'GET', url, headers: { Authorization: `Bearer ${token.accessToken}`, }, } : { method: 'GET', url, }, true); return body; }; const subPrefix = 'go'; userSvc.setInfoResolver('google', subPrefix, async (sub) => { try { const googleToken = Object.values(store.getters['data/googleTokensBySub'])[0]; const body = await getUser(sub, googleToken); const name = (body.names && body.names[0]) || {}; const photo = (body.photos && body.photos[0]) || {}; return { id: `${subPrefix}:${sub}`, name: name.displayName, imageUrl: (photo.url || '').replace(/\bsz?=\d+$/, 'sz=40'), }; } catch (err) { if (err.status !== 404) { throw new Error('RETRY'); } throw err; } }); export default { subPrefix, folderMimeType: 'application/vnd.google-apps.folder', driveState, driveActionFolder: null, driveActionFiles: [], async $request(token, options) { try { return (await networkSvc.request({ ...options, headers: { ...options.headers || {}, Authorization: `Bearer ${token.accessToken}`, }, }, true)).body; } catch (err) { const { reason } = (((err.body || {}).error || {}).errors || [])[0] || {}; if (reason === 'authError') { // Mark the token as revoked and get a new one store.dispatch('data/addGoogleToken', { ...token, expiresOn: 0, }); // Refresh token and retry const refreshedToken = await this.refreshToken(token, token.scopes); return this.$request(refreshedToken, options); } throw err; } }, /** * https://developers.google.com/identity/protocols/OpenIDConnect */ async startOauth2(scopes, sub = null, silent = false) { const clientId = store.getters['data/serverConf'].googleClientId; // Get an OAuth2 code const { accessToken, expiresIn, idToken } = await networkSvc.startOauth2( 'https://accounts.google.com/o/oauth2/v2/auth', { client_id: clientId, response_type: 'token id_token', scope: ['openid', 'profile', ...scopes].join(' '), hd: appsDomain, login_hint: sub, prompt: silent ? 'none' : null, nonce: utils.uid(), }, silent, ); // Call the token info endpoint const { body } = await networkSvc.request({ method: 'POST', url: 'https://www.googleapis.com/oauth2/v3/tokeninfo', params: { access_token: accessToken, }, }, true); // Check the returned client ID consistency if (body.aud !== clientId) { throw new Error('Client ID inconsistent.'); } // Check the returned sub consistency if (sub && `${body.sub}` !== sub) { throw new Error('Google account ID not expected.'); } // Build token object including scopes and sub const existingToken = store.getters['data/googleTokensBySub'][body.sub]; const token = { scopes, accessToken, expiresOn: Date.now() + (expiresIn * 1000), idToken, sub: body.sub, name: (existingToken || {}).name || 'Someone', isLogin: !store.getters['workspace/mainWorkspaceToken'] && scopes.includes('https://www.googleapis.com/auth/drive.appdata'), isSponsor: false, isDrive: scopes.includes('https://www.googleapis.com/auth/drive') || scopes.includes('https://www.googleapis.com/auth/drive.file'), isBlogger: scopes.includes('https://www.googleapis.com/auth/blogger'), isPhotos: scopes.includes('https://www.googleapis.com/auth/photos'), driveFullAccess: scopes.includes('https://www.googleapis.com/auth/drive'), }; // Call the user info endpoint const user = await getUser('me', token); const userId = user.resourceName.split('/')[1]; const name = user.names[0] || {}; const photo = user.photos[0] || {}; if (name.displayName) { token.name = name.displayName; } userSvc.addUserInfo({ id: `${subPrefix}:${userId}`, name: name.displayName, imageUrl: (photo.url || '').replace(/\bsz?=\d+$/, 'sz=40'), }); if (existingToken) { // We probably retrieved a new token with restricted scopes. // That's no problem, token will be refreshed later with merged scopes. // Restore flags Object.assign(token, { isLogin: existingToken.isLogin || token.isLogin, isSponsor: existingToken.isSponsor, isDrive: existingToken.isDrive || token.isDrive, isBlogger: existingToken.isBlogger || token.isBlogger, isPhotos: existingToken.isPhotos || token.isPhotos, driveFullAccess: existingToken.driveFullAccess || token.driveFullAccess, }); } if (token.isLogin) { try { const res = await networkSvc.request({ method: 'GET', url: 'userInfo', params: { idToken: token.idToken, }, }); token.isSponsor = res.body.sponsorUntil > Date.now(); if (token.isSponsor) { badgeSvc.addBadge('sponsor'); } } catch (err) { // Ignore } } // Add token to google tokens await store.dispatch('data/addGoogleToken', token); return token; }, async refreshToken(token, scopes = []) { const { sub } = token; const lastToken = store.getters['data/googleTokensBySub'][sub]; const mergedScopes = [...new Set([ ...scopes, ...lastToken.scopes, ])]; if ( // If we already have permissions for the requested scopes mergedScopes.length === lastToken.scopes.length && // And lastToken is not expired lastToken.expiresOn > Date.now() + tokenExpirationMargin && // And in case of a login token, ID token is still valid (!lastToken.isLogin || checkIdToken(lastToken.idToken)) ) { return lastToken; } // New scopes are requested or existing token is about to expire. // Try to get a new token in background try { return await this.startOauth2(mergedScopes, sub, true); } catch (err) { // If it fails try to popup a window if (store.state.offline) { throw err; } await store.dispatch('modal/open', { type: 'providerRedirection', name: 'Google', }); return this.startOauth2(mergedScopes, sub); } }, signin() { return this.startOauth2(driveAppDataScopes); }, async addDriveAccount(fullAccess = false, sub = null) { const token = await this.startOauth2(getDriveScopes({ driveFullAccess: fullAccess }), sub); badgeSvc.addBadge('addGoogleDriveAccount'); return token; }, async addBloggerAccount() { const token = await this.startOauth2(bloggerScopes); badgeSvc.addBadge('addBloggerAccount'); return token; }, async addPhotosAccount() { const token = await this.startOauth2(photosScopes); badgeSvc.addBadge('addGooglePhotosAccount'); return token; }, /** * https://developers.google.com/drive/v3/reference/files/create * https://developers.google.com/drive/v3/reference/files/update * https://developers.google.com/drive/v3/web/simple-upload */ async $uploadFile({ refreshedToken, name, parents, appProperties, media = null, mediaType = null, fileId = null, oldParents = null, ifNotTooLate = cb => cb(), }) { // Refreshing a token can take a while if an oauth window pops up, make sure it's not too late return ifNotTooLate(() => { const options = { method: 'POST', url: 'https://www.googleapis.com/drive/v3/files', }; const params = { supportsTeamDrives: true, }; const metadata = { name, appProperties }; if (fileId) { options.method = 'PATCH'; options.url = `https://www.googleapis.com/drive/v3/files/${fileId}`; if (parents && oldParents) { params.addParents = parents .filter(parent => !oldParents.includes(parent)) .join(','); params.removeParents = oldParents .filter(parent => !parents.includes(parent)) .join(','); } } else if (parents) { metadata.parents = parents; } if (media) { const boundary = `-------${utils.uid()}`; const delimiter = `\r\n--${boundary}\r\n`; const closeDelimiter = `\r\n--${boundary}--`; let multipartRequestBody = ''; multipartRequestBody += delimiter; multipartRequestBody += 'Content-Type: application/json; charset=UTF-8\r\n\r\n'; multipartRequestBody += JSON.stringify(metadata); multipartRequestBody += delimiter; multipartRequestBody += `Content-Type: ${mediaType || 'text/plain'}; charset=UTF-8\r\n\r\n`; multipartRequestBody += media; multipartRequestBody += closeDelimiter; options.url = options.url.replace( 'https://www.googleapis.com/', 'https://www.googleapis.com/upload/', ); return this.$request(refreshedToken, { ...options, params: { ...params, uploadType: 'multipart', }, headers: { 'Content-Type': `multipart/mixed; boundary="${boundary}"`, }, body: multipartRequestBody, }); } if (mediaType) { metadata.mimeType = mediaType; } return this.$request(refreshedToken, { ...options, body: metadata, params, }); }); }, async uploadFile({ token, name, parents, appProperties, media, mediaType, fileId, oldParents, ifNotTooLate, }) { const refreshedToken = await this.refreshToken(token, getDriveScopes(token)); return this.$uploadFile({ refreshedToken, name, parents, appProperties, media, mediaType, fileId, oldParents, ifNotTooLate, }); }, async uploadAppDataFile({ token, name, media, fileId, ifNotTooLate, }) { const refreshedToken = await this.refreshToken(token, driveAppDataScopes); return this.$uploadFile({ refreshedToken, name, parents: ['appDataFolder'], media, fileId, ifNotTooLate, }); }, /** * https://developers.google.com/drive/v3/reference/files/get */ async getFile(token, id) { const refreshedToken = await this.refreshToken(token, getDriveScopes(token)); return this.$request(refreshedToken, { method: 'GET', url: `https://www.googleapis.com/drive/v3/files/${id}`, params: { fields: 'id,name,mimeType,appProperties,teamDriveId', supportsTeamDrives: true, }, }); }, /** * https://developers.google.com/drive/v3/web/manage-downloads */ async $downloadFile(refreshedToken, id) { return this.$request(refreshedToken, { method: 'GET', url: `https://www.googleapis.com/drive/v3/files/${id}?alt=media`, raw: true, }); }, async downloadFile(token, id) { const refreshedToken = await this.refreshToken(token, getDriveScopes(token)); return this.$downloadFile(refreshedToken, id); }, async downloadAppDataFile(token, id) { const refreshedToken = await this.refreshToken(token, driveAppDataScopes); return this.$downloadFile(refreshedToken, id); }, /** * https://developers.google.com/drive/v3/reference/files/delete */ async $removeFile(refreshedToken, id, ifNotTooLate = cb => cb()) { // Refreshing a token can take a while if an oauth window pops up, so check if it's too late return ifNotTooLate(() => this.$request(refreshedToken, { method: 'DELETE', url: `https://www.googleapis.com/drive/v3/files/${id}`, params: { supportsTeamDrives: true, }, })); }, async removeFile(token, id, ifNotTooLate) { const refreshedToken = await this.refreshToken(token, getDriveScopes(token)); return this.$removeFile(refreshedToken, id, ifNotTooLate); }, async removeAppDataFile(token, id, ifNotTooLate = cb => cb()) { const refreshedToken = await this.refreshToken(token, driveAppDataScopes); return this.$removeFile(refreshedToken, id, ifNotTooLate); }, /** * https://developers.google.com/drive/v3/reference/revisions/list */ async $getFileRevisions(refreshedToken, id) { const allRevisions = []; const getPage = async (pageToken) => { const { revisions, nextPageToken } = await this.$request(refreshedToken, { method: 'GET', url: `https://www.googleapis.com/drive/v3/files/${id}/revisions`, params: { pageToken, pageSize: 1000, fields: 'nextPageToken,revisions(id,modifiedTime,lastModifyingUser/permissionId,lastModifyingUser/displayName,lastModifyingUser/photoLink)', }, }); revisions.forEach((revision) => { userSvc.addUserInfo({ id: `${subPrefix}:${revision.lastModifyingUser.permissionId}`, name: revision.lastModifyingUser.displayName, imageUrl: revision.lastModifyingUser.photoLink || '', }); allRevisions.push(revision); }); if (nextPageToken) { return getPage(nextPageToken); } return allRevisions; }; return getPage(); }, async getFileRevisions(token, id) { const refreshedToken = await this.refreshToken(token, getDriveScopes(token)); return this.$getFileRevisions(refreshedToken, id); }, async getAppDataFileRevisions(token, id) { const refreshedToken = await this.refreshToken(token, driveAppDataScopes); return this.$getFileRevisions(refreshedToken, id); }, /** * https://developers.google.com/drive/v3/reference/revisions/get */ async $downloadFileRevision(refreshedToken, id, revisionId) { return this.$request(refreshedToken, { method: 'GET', url: `https://www.googleapis.com/drive/v3/files/${id}/revisions/${revisionId}?alt=media`, raw: true, }); }, async downloadFileRevision(token, fileId, revisionId) { const refreshedToken = await this.refreshToken(token, getDriveScopes(token)); return this.$downloadFileRevision(refreshedToken, fileId, revisionId); }, async downloadAppDataFileRevision(token, fileId, revisionId) { const refreshedToken = await this.refreshToken(token, driveAppDataScopes); return this.$downloadFileRevision(refreshedToken, fileId, revisionId); }, /** * https://developers.google.com/drive/v3/reference/changes/list */ async getChanges(token, startPageToken, isAppData, teamDriveId = null) { const result = { changes: [], }; let fileFields = 'file/name'; if (!isAppData) { fileFields += ',file/parents,file/mimeType,file/appProperties'; } const refreshedToken = await this.refreshToken( token, isAppData ? driveAppDataScopes : getDriveScopes(token), ); const getPage = async (pageToken = '1') => { const { changes, nextPageToken, newStartPageToken } = await this.$request(refreshedToken, { method: 'GET', url: 'https://www.googleapis.com/drive/v3/changes', params: { pageToken, spaces: isAppData ? 'appDataFolder' : 'drive', pageSize: 1000, fields: `nextPageToken,newStartPageToken,changes(fileId,${fileFields})`, supportsTeamDrives: true, includeTeamDriveItems: !!teamDriveId, teamDriveId, }, }); result.changes = [...result.changes, ...changes.filter(item => item.fileId)]; if (nextPageToken) { return getPage(nextPageToken); } result.startPageToken = newStartPageToken; return result; }; return getPage(startPageToken); }, /** * https://developers.google.com/blogger/docs/3.0/reference/blogs/getByUrl * https://developers.google.com/blogger/docs/3.0/reference/posts/insert * https://developers.google.com/blogger/docs/3.0/reference/posts/update */ async uploadBlogger({ token, blogUrl, blogId, postId, title, content, labels, isDraft, published, isPage, }) { const refreshedToken = await this.refreshToken(token, bloggerScopes); // Get the blog ID const blog = { id: blogId }; if (!blog.id) { blog.id = (await this.$request(refreshedToken, { url: 'https://www.googleapis.com/blogger/v3/blogs/byurl', params: { url: blogUrl, }, })).id; } // Create/update the post/page const path = isPage ? 'pages' : 'posts'; let options = { method: 'POST', url: `https://www.googleapis.com/blogger/v3/blogs/${blog.id}/${path}/`, body: { kind: isPage ? 'blogger#page' : 'blogger#post', blog, title, content, }, }; if (labels) { options.body.labels = labels; } if (published) { options.body.published = published.toISOString(); } // If it's an update if (postId) { options.method = 'PUT'; options.url += postId; options.body.id = postId; } const post = await this.$request(refreshedToken, options); if (isPage) { return post; } // Revert/publish post options = { method: 'POST', url: `https://www.googleapis.com/blogger/v3/blogs/${post.blog.id}/posts/${post.id}/`, params: {}, }; if (isDraft) { options.url += 'revert'; } else { options.url += 'publish'; if (published) { options.params.publishDate = published.toISOString(); } } return this.$request(refreshedToken, options); }, /** * https://developers.google.com/picker/docs/ */ async openPicker(token, type = 'doc') { const scopes = type === 'img' ? photosScopes : getDriveScopes(token); if (!window.google) { await networkSvc.loadScript('https://apis.google.com/js/api.js'); await new Promise((resolve, reject) => window.gapi.load('picker', { callback: resolve, onerror: reject, timeout: 30000, ontimeout: reject, })); } const refreshedToken = await this.refreshToken(token, scopes); const { google } = window; return new Promise((resolve) => { let picker; const pickerBuilder = new google.picker.PickerBuilder() .setOAuthToken(refreshedToken.accessToken) .enableFeature(google.picker.Feature.SUPPORT_TEAM_DRIVES) .hideTitleBar() .setCallback((data) => { switch (data[google.picker.Response.ACTION]) { case google.picker.Action.PICKED: case google.picker.Action.CANCEL: resolve(data.docs || []); picker.dispose(); break; default: } }); switch (type) { default: case 'doc': { const mimeTypes = [ 'text/plain', 'text/x-markdown', 'application/octet-stream', ].join(','); const view = new google.picker.DocsView(google.picker.ViewId.DOCS); view.setMimeTypes(mimeTypes); pickerBuilder.addView(view); const teamDriveView = new google.picker.DocsView(google.picker.ViewId.DOCS); teamDriveView.setMimeTypes(mimeTypes); teamDriveView.setEnableTeamDrives(true); pickerBuilder.addView(teamDriveView); pickerBuilder.enableFeature(google.picker.Feature.MULTISELECT_ENABLED); pickerBuilder.enableFeature(google.picker.Feature.SUPPORT_TEAM_DRIVES); break; } case 'folder': { const folderView = new google.picker.DocsView(google.picker.ViewId.FOLDERS); folderView.setSelectFolderEnabled(true); folderView.setMimeTypes(this.folderMimeType); pickerBuilder.addView(folderView); const teamDriveView = new google.picker.DocsView(google.picker.ViewId.FOLDERS); teamDriveView.setSelectFolderEnabled(true); teamDriveView.setEnableTeamDrives(true); teamDriveView.setMimeTypes(this.folderMimeType); pickerBuilder.addView(teamDriveView); break; } case 'img': { const view = new google.picker.PhotosView(); view.setType('highlights'); pickerBuilder.addView(view); pickerBuilder.addView(google.picker.ViewId.PHOTO_UPLOAD); break; } } picker = pickerBuilder.build(); picker.setVisible(true); }); }, }; ================================================ FILE: src/services/providers/helpers/wordpressHelper.js ================================================ import networkSvc from '../../networkSvc'; import store from '../../../store'; import badgeSvc from '../../badgeSvc'; const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (WordPress tokens expire after 2 weeks) const request = (token, options) => networkSvc.request({ ...options, headers: { ...options.headers || {}, Authorization: `Bearer ${token.accessToken}`, }, }) .then(res => res.body); export default { /** * https://developer.wordpress.com/docs/oauth2/ */ async startOauth2(sub = null, silent = false) { const clientId = store.getters['data/serverConf'].wordpressClientId; // Get an OAuth2 code const { accessToken, expiresIn } = await networkSvc.startOauth2( 'https://public-api.wordpress.com/oauth2/authorize', { client_id: clientId, response_type: 'token', scope: 'global', }, silent, ); // Call the user info endpoint const body = await request({ accessToken }, { url: 'https://public-api.wordpress.com/rest/v1.1/me', }); // Check the returned sub consistency if (sub && `${body.ID}` !== sub) { throw new Error('WordPress account ID not expected.'); } // Build token object including scopes and sub const token = { accessToken, expiresOn: Date.now() + (expiresIn * 1000), name: body.display_name, sub: `${body.ID}`, }; // Add token to wordpress tokens store.dispatch('data/addWordpressToken', token); return token; }, async refreshToken(token) { const { sub } = token; const lastToken = store.getters['data/wordpressTokensBySub'][sub]; if (lastToken.expiresOn > Date.now() + tokenExpirationMargin) { return lastToken; } // Existing token is going to expire. // Try to get a new token in background await store.dispatch('modal/open', { type: 'providerRedirection', name: 'WordPress', }); return this.startOauth2(sub); }, async addAccount(fullAccess = false) { const token = await this.startOauth2(fullAccess); badgeSvc.addBadge('addWordpressAccount'); return token; }, /** * https://developer.wordpress.com/docs/api/1.2/post/sites/%24site/posts/new/ * https://developer.wordpress.com/docs/api/1.2/post/sites/%24site/posts/%24post_ID/ */ async uploadPost({ token, domain, siteId, postId, title, content, tags, categories, excerpt, author, featuredImage, status, date, }) { const refreshedToken = await this.refreshToken(token); return request(refreshedToken, { method: 'POST', url: `https://public-api.wordpress.com/rest/v1.2/sites/${siteId || domain}/posts/${postId || 'new'}`, body: { content, title, tags, categories, excerpt, author, featured_image: featuredImage || '', status, date: date && date.toISOString(), }, }); }, }; ================================================ FILE: src/services/providers/helpers/zendeskHelper.js ================================================ import networkSvc from '../../networkSvc'; import store from '../../../store'; import badgeSvc from '../../badgeSvc'; const request = (token, options) => networkSvc.request({ ...options, headers: { ...options.headers || {}, Authorization: `Bearer ${token.accessToken}`, }, }) .then(res => res.body); export default { /** * https://support.zendesk.com/hc/en-us/articles/203663836-Using-OAuth-authentication-with-your-application */ async startOauth2(subdomain, clientId, sub = null, silent = false) { // Get an OAuth2 code const { accessToken } = await networkSvc.startOauth2( `https://${subdomain}.zendesk.com/oauth/authorizations/new`, { client_id: clientId, response_type: 'token', scope: 'read hc:write', }, silent, ); // Call the user info endpoint const { user } = await request({ accessToken }, { url: `https://${subdomain}.zendesk.com/api/v2/users/me.json`, }); const uniqueSub = `${subdomain}/${user.id}`; // Check the returned sub consistency if (sub && uniqueSub !== sub) { throw new Error('Zendesk account ID not expected.'); } // Build token object including scopes and sub const token = { accessToken, name: user.name, subdomain, sub: uniqueSub, }; // Add token to zendesk tokens store.dispatch('data/addZendeskToken', token); return token; }, async addAccount(subdomain, clientId) { const token = await this.startOauth2(subdomain, clientId); badgeSvc.addBadge('addZendeskAccount'); return token; }, /** * https://developer.zendesk.com/rest_api/docs/help_center/articles */ async uploadArticle({ token, sectionId, articleId, title, content, labels, locale, isDraft, }) { const article = { title, body: content, locale, draft: isDraft, }; if (articleId) { // Update article await request(token, { method: 'PUT', url: `https://${token.subdomain}.zendesk.com/api/v2/help_center/articles/${articleId}/translations/${locale}.json`, body: { translation: article }, }); // Add labels if (labels) { await request(token, { method: 'PUT', url: `https://${token.subdomain}.zendesk.com/api/v2/help_center/articles/${articleId}.json`, body: { article: { label_names: labels, }, }, }); } return articleId; } // Create new article if (labels) { article.label_names = labels; } const body = await request(token, { method: 'POST', url: `https://${token.subdomain}.zendesk.com/api/v2/help_center/sections/${sectionId}/articles.json`, body: { article }, }); return `${body.article.id}`; }, }; ================================================ FILE: src/services/providers/wordpressProvider.js ================================================ import store from '../../store'; import wordpressHelper from './helpers/wordpressHelper'; import Provider from './common/Provider'; export default new Provider({ id: 'wordpress', name: 'WordPress', getToken({ sub }) { return store.getters['data/wordpressTokensBySub'][sub]; }, getLocationUrl({ siteId, postId }) { return `https://wordpress.com/post/${siteId}/${postId}`; }, getLocationDescription({ postId }) { return postId; }, async publish(token, html, metadata, publishLocation) { const post = await wordpressHelper.uploadPost({ ...publishLocation, ...metadata, token, content: html, }); return { ...publishLocation, siteId: `${post.site_ID}`, postId: `${post.ID}`, }; }, makeLocation(token, domain, postId) { const location = { providerId: this.id, sub: token.sub, domain, }; if (postId) { location.postId = postId; } return location; }, }); ================================================ FILE: src/services/providers/zendeskProvider.js ================================================ import store from '../../store'; import zendeskHelper from './helpers/zendeskHelper'; import Provider from './common/Provider'; export default new Provider({ id: 'zendesk', name: 'Zendesk', getToken({ sub }) { return store.getters['data/zendeskTokensBySub'][sub]; }, getLocationUrl({ sub, locale, articleId }) { const token = this.getToken({ sub }); return `https://${token.subdomain}.zendesk.com/hc/${locale}/articles/${articleId}`; }, getLocationDescription({ articleId }) { return articleId; }, async publish(token, html, metadata, publishLocation) { const articleId = await zendeskHelper.uploadArticle({ ...publishLocation, token, title: metadata.title, content: html, labels: metadata.tags, isDraft: metadata.status === 'draft', }); return { ...publishLocation, articleId, }; }, makeLocation(token, sectionId, locale, articleId) { const location = { providerId: this.id, sub: token.sub, sectionId, locale, }; if (articleId) { location.articleId = articleId; } return location; }, }); ================================================ FILE: src/services/publishSvc.js ================================================ import localDbSvc from './localDbSvc'; import store from '../store'; import utils from './utils'; import networkSvc from './networkSvc'; import exportSvc from './exportSvc'; import providerRegistry from './providers/common/providerRegistry'; import workspaceSvc from './workspaceSvc'; import badgeSvc from './badgeSvc'; const hasCurrentFilePublishLocations = () => !!store.getters['publishLocation/current'].length; const loader = type => fileId => localDbSvc.loadItem(`${fileId}/${type}`) // Item does not exist, create it .catch(() => store.commit(`${type}/setItem`, { id: `${fileId}/${type}`, })); const loadContent = loader('content'); const ensureArray = (value) => { if (!value) { return []; } if (!Array.isArray(value)) { return `${value}`.trim().split(/\s*,\s*/); } return value; }; const ensureString = (value, defaultValue) => { if (!value) { return defaultValue; } return `${value}`; }; const ensureDate = (value, defaultValue) => { if (!value) { return defaultValue; } return new Date(`${value}`); }; const publish = async (publishLocation) => { const { fileId } = publishLocation; const template = store.getters['data/allTemplatesById'][publishLocation.templateId]; const html = await exportSvc.applyTemplate(fileId, template); const content = await localDbSvc.loadItem(`${fileId}/content`); const file = store.state.file.itemsById[fileId]; const properties = utils.computeProperties(content.properties); const provider = providerRegistry.providersById[publishLocation.providerId]; const token = provider.getToken(publishLocation); const metadata = { title: ensureString(properties.title, file.name), author: ensureString(properties.author), tags: ensureArray(properties.tags), categories: ensureArray(properties.categories), excerpt: ensureString(properties.excerpt), featuredImage: ensureString(properties.featuredImage), status: ensureString(properties.status), date: ensureDate(properties.date, new Date()), }; return provider.publish(token, html, metadata, publishLocation); }; const publishFile = async (fileId) => { let counter = 0; await loadContent(fileId); const publishLocations = [ ...store.getters['publishLocation/filteredGroupedByFileId'][fileId] || [], ]; try { await utils.awaitSequence(publishLocations, async (publishLocation) => { await store.dispatch('queue/doWithLocation', { location: publishLocation, action: async () => { const publishLocationToStore = await publish(publishLocation); try { // Replace publish location if modified if (utils.serializeObject(publishLocation) !== utils.serializeObject(publishLocationToStore) ) { store.commit('publishLocation/patchItem', publishLocationToStore); workspaceSvc.ensureUniqueLocations(); } counter += 1; } catch (err) { if (store.state.offline) { throw err; } console.error(err); // eslint-disable-line no-console store.dispatch('notification/error', err); } }, }); }); const file = store.state.file.itemsById[fileId]; store.dispatch('notification/info', `"${file.name}" was published to ${counter} location(s).`); } finally { await localDbSvc.unloadContents(); } }; const requestPublish = () => { // No publish in light mode if (store.state.light) { return; } store.dispatch('queue/enqueuePublishRequest', async () => { let intervalId; const attempt = async () => { // Only start publishing when these conditions are met if (networkSvc.isUserActive()) { clearInterval(intervalId); if (!hasCurrentFilePublishLocations()) { // Cancel publish throw new Error('Publish not possible.'); } await publishFile(store.getters['file/current'].id); badgeSvc.addBadge('triggerPublish'); } }; intervalId = utils.setInterval(() => attempt(), 1000); return attempt(); }); }; const createPublishLocation = (publishLocation, featureId) => { const currentFile = store.getters['file/current']; publishLocation.fileId = currentFile.id; store.dispatch( 'queue/enqueue', async () => { const publishLocationToStore = await publish(publishLocation); workspaceSvc.addPublishLocation(publishLocationToStore); store.dispatch('notification/info', `A new publication location was added to "${currentFile.name}".`); if (featureId) { badgeSvc.addBadge(featureId); } }, ); }; export default { requestPublish, createPublishLocation, }; ================================================ FILE: src/services/syncSvc.js ================================================ import localDbSvc from './localDbSvc'; import store from '../store'; import utils from './utils'; import diffUtils from './diffUtils'; import networkSvc from './networkSvc'; import providerRegistry from './providers/common/providerRegistry'; import googleDriveAppDataProvider from './providers/googleDriveAppDataProvider'; import './providers/couchdbWorkspaceProvider'; import './providers/githubWorkspaceProvider'; import './providers/gitlabWorkspaceProvider'; import './providers/googleDriveWorkspaceProvider'; import tempFileSvc from './tempFileSvc'; import workspaceSvc from './workspaceSvc'; import constants from '../data/constants'; import badgeSvc from './badgeSvc'; const minAutoSyncEvery = 60 * 1000; // 60 sec const inactivityThreshold = 3 * 1000; // 3 sec const restartSyncAfter = 30 * 1000; // 30 sec const restartContentSyncAfter = 1000; // Enough to detect an authorize pop up const checkSponsorshipAfter = (5 * 60 * 1000) + (30 * 1000); // tokenExpirationMargin + 30 sec const maxContentHistory = 20; const LAST_SEEN = 0; const LAST_MERGED = 1; const LAST_SENT = 2; let actionProvider; let workspaceProvider; /** * Use a lock in the local storage to prevent multiple windows concurrency. */ let lastSyncActivity; const getLastStoredSyncActivity = () => parseInt(localStorage.getItem(store.getters['workspace/lastSyncActivityKey']), 10) || 0; /** * Return true if workspace sync is possible. */ const isWorkspaceSyncPossible = () => !!store.getters['workspace/syncToken']; /** * Return true if file has at least one explicit sync location. */ const hasCurrentFileSyncLocations = () => !!store.getters['syncLocation/current'].length; /** * Return true if we are online and we have something to sync. */ const isSyncPossible = () => !store.state.offline && (isWorkspaceSyncPossible() || hasCurrentFileSyncLocations()); /** * Return true if we are the many window, ie we have the lastSyncActivity lock. */ const isSyncWindow = () => { const storedLastSyncActivity = getLastStoredSyncActivity(); return lastSyncActivity === storedLastSyncActivity || Date.now() > inactivityThreshold + storedLastSyncActivity; }; /** * Return true if auto sync can start, ie if lastSyncActivity is old enough. */ const isAutoSyncReady = () => { let { autoSyncEvery } = store.getters['data/computedSettings']; if (autoSyncEvery < minAutoSyncEvery) { autoSyncEvery = minAutoSyncEvery; } return Date.now() > autoSyncEvery + getLastStoredSyncActivity(); }; /** * Update the lastSyncActivity, assuming we have the lock. */ const setLastSyncActivity = () => { const currentDate = Date.now(); lastSyncActivity = currentDate; localStorage.setItem(store.getters['workspace/lastSyncActivityKey'], currentDate); }; /** * Upgrade hashes if syncedContent is from an old version */ const upgradeSyncedContent = (syncedContent) => { if (syncedContent.v) { return syncedContent; } const hashUpgrades = {}; const historyData = {}; const syncHistory = {}; Object.entries(syncedContent.historyData).forEach(([hash, content]) => { const newContent = utils.addItemHash(content); historyData[newContent.hash] = newContent; hashUpgrades[hash] = newContent.hash; }); Object.entries(syncedContent.syncHistory).forEach(([id, hashEntries]) => { syncHistory[id] = hashEntries.map(hash => hashUpgrades[hash]); }); return { ...syncedContent, historyData, syncHistory, v: 1, }; }; /** * Clean a syncedContent. */ const cleanSyncedContent = (syncedContent) => { // Clean syncHistory from removed syncLocations Object.keys(syncedContent.syncHistory).forEach((syncLocationId) => { if (syncLocationId !== 'main' && !store.state.syncLocation.itemsById[syncLocationId]) { delete syncedContent.syncHistory[syncLocationId]; } }); const allSyncLocationHashSet = new Set([] .concat(...Object.keys(syncedContent.syncHistory) .map(id => syncedContent.syncHistory[id]))); // Clean historyData from unused contents Object.keys(syncedContent.historyData) .map(hash => parseInt(hash, 10)) .forEach((hash) => { if (!allSyncLocationHashSet.has(hash)) { delete syncedContent.historyData[hash]; } }); }; /** * Apply changes retrieved from the workspace provider. Update sync data accordingly. */ const applyChanges = (changes) => { const allItemsById = { ...store.getters.allItemsById }; const syncDataById = { ...store.getters['data/syncDataById'] }; const idsToKeep = {}; let saveSyncData = false; let getExistingItem; if (store.getters['workspace/currentWorkspaceIsGit']) { const itemsByGitPath = { ...store.getters.itemsByGitPath }; getExistingItem = existingSyncData => existingSyncData && itemsByGitPath[existingSyncData.id]; } else { getExistingItem = existingSyncData => existingSyncData && allItemsById[existingSyncData.itemId]; } // Process each change changes.forEach((change) => { const existingSyncData = syncDataById[change.syncDataId]; const existingItem = getExistingItem(existingSyncData); // If item was removed if (!change.item && existingSyncData) { if (syncDataById[change.syncDataId]) { delete syncDataById[change.syncDataId]; saveSyncData = true; } if (existingItem) { // Remove object from the store store.commit(`${existingItem.type}/deleteItem`, existingItem.id); delete allItemsById[existingItem.id]; } // If item was modified } else if (change.item && change.item.hash) { idsToKeep[change.item.id] = true; if ((existingSyncData || {}).hash !== change.syncData.hash) { syncDataById[change.syncDataId] = change.syncData; saveSyncData = true; } if ( // If no sync data or existing one is different (existingSyncData || {}).hash !== change.item.hash // And no existing item or existing item is different && (existingItem || {}).hash !== change.item.hash // And item is not content nor data, which will be merged later && change.item.type !== 'content' && change.item.type !== 'data' ) { store.commit(`${change.item.type}/setItem`, change.item); allItemsById[change.item.id] = change.item; } } }); if (saveSyncData) { store.dispatch('data/setSyncDataById', syncDataById); // Sanitize the workspace workspaceSvc.sanitizeWorkspace(idsToKeep); } }; /** * Create a sync location by uploading the current file content. */ const createSyncLocation = (syncLocation) => { const currentFile = store.getters['file/current']; const fileId = currentFile.id; syncLocation.fileId = fileId; // Use deepCopy to freeze the item const content = utils.deepCopy(store.getters['content/current']); store.dispatch( 'queue/enqueue', async () => { const provider = providerRegistry.providersById[syncLocation.providerId]; const token = provider.getToken(syncLocation); const updatedSyncLocation = await provider.uploadContent(token, { ...content, history: [content.hash], }, syncLocation); await localDbSvc.loadSyncedContent(fileId); const newSyncedContent = utils.deepCopy(upgradeSyncedContent(store.state.syncedContent.itemsById[`${fileId}/syncedContent`])); const newSyncHistoryItem = []; newSyncedContent.syncHistory[syncLocation.id] = newSyncHistoryItem; newSyncHistoryItem[LAST_SEEN] = content.hash; newSyncHistoryItem[LAST_SENT] = content.hash; newSyncedContent.historyData[content.hash] = content; store.commit('syncedContent/patchItem', newSyncedContent); workspaceSvc.addSyncLocation(updatedSyncLocation); store.dispatch('notification/info', `A new synchronized location was added to "${currentFile.name}".`); }, ); }; /** * Prevent from sending new data too long after old data has been fetched. */ const tooLateChecker = (timeout) => { const tooLateAfter = Date.now() + timeout; return (cb) => { if (tooLateAfter < Date.now()) { throw new Error('TOO_LATE'); } return cb(); }; }; /** * Return true if file is in the temp folder or is a welcome file. */ const isTempFile = (fileId) => { const contentId = `${fileId}/content`; if (store.getters['data/syncDataByItemId'][contentId]) { // If file has already been synced, let's not consider it a temp file return false; } const file = store.state.file.itemsById[fileId]; const content = store.state.content.itemsById[contentId]; if (!file || !content) { return false; } if (file.parentId === 'temp') { return true; } const locations = [ ...store.getters['syncLocation/filteredGroupedByFileId'][fileId] || [], ...store.getters['publishLocation/filteredGroupedByFileId'][fileId] || [], ]; if (locations.length) { // If file has sync/publish locations, it's not a temp file return false; } // Return true if it's a welcome file that has no discussion const { welcomeFileHashes } = store.getters['data/localSettings']; const hash = utils.hash(content.text); const hasDiscussions = Object.keys(content.discussions).length; return file.name === 'Welcome file' && welcomeFileHashes[hash] && !hasDiscussions; }; /** * Patch sync data if some have changed in the result. */ const updateSyncData = (result) => { [ result.syncData, result.contentSyncData, result.fileSyncData, ].forEach((syncData) => { if (syncData) { const oldSyncData = store.getters['data/syncDataById'][syncData.id]; if (utils.serializeObject(oldSyncData) !== utils.serializeObject(syncData)) { store.dispatch('data/patchSyncDataById', { [syncData.id]: syncData, }); } } }); return result; }; class SyncContext { restartSkipContents = false; attempted = {}; } /** * Sync one file with all its locations. */ const syncFile = async (fileId, syncContext = new SyncContext()) => { const contentId = `${fileId}/content`; syncContext.attempted[contentId] = true; await localDbSvc.loadSyncedContent(fileId); try { await localDbSvc.loadItem(contentId); } catch (e) { // Item may not exist if content has not been downloaded yet } const getSyncedContent = () => upgradeSyncedContent(store.state.syncedContent.itemsById[`${fileId}/syncedContent`]); const getSyncHistoryItem = syncLocationId => getSyncedContent().syncHistory[syncLocationId]; try { if (isTempFile(fileId)) { return; } const syncLocations = [ ...store.getters['syncLocation/filteredGroupedByFileId'][fileId] || [], ]; if (isWorkspaceSyncPossible()) { syncLocations.unshift({ id: 'main', providerId: workspaceProvider.id, fileId }); } await utils.awaitSequence(syncLocations, async (syncLocation) => { const provider = providerRegistry.providersById[syncLocation.providerId]; if (!provider) { return; } const token = provider.getToken(syncLocation); if (!token) { return; } const downloadContent = async () => { // On simple provider, call simply downloadContent if (syncLocation.id !== 'main') { return provider.downloadContent(token, syncLocation); } // On workspace provider, call downloadWorkspaceContent const oldContentSyncData = store.getters['data/syncDataByItemId'][contentId]; const oldFileSyncData = store.getters['data/syncDataByItemId'][fileId]; if (!oldContentSyncData || !oldFileSyncData) { return null; } const { content } = updateSyncData(await provider.downloadWorkspaceContent({ token, contentId, contentSyncData: oldContentSyncData, fileSyncData: oldFileSyncData, })); // Return the downloaded content return content; }; const uploadContent = async (content, ifNotTooLate) => { // On simple provider, call simply uploadContent if (syncLocation.id !== 'main') { return provider.uploadContent(token, content, syncLocation, ifNotTooLate); } // On workspace provider, call uploadWorkspaceContent const oldContentSyncData = store.getters['data/syncDataByItemId'][contentId]; if (oldContentSyncData && oldContentSyncData.hash === content.hash) { return syncLocation; } const oldFileSyncData = store.getters['data/syncDataByItemId'][fileId]; updateSyncData(await provider.uploadWorkspaceContent({ token, content, // Use deepCopy to freeze item file: utils.deepCopy(store.state.file.itemsById[fileId]), contentSyncData: oldContentSyncData, fileSyncData: oldFileSyncData, ifNotTooLate, })); // Return syncLocation return syncLocation; }; const doSyncLocation = async () => { const serverContent = await downloadContent(token, syncLocation); const syncedContent = getSyncedContent(); const syncHistoryItem = getSyncHistoryItem(syncLocation.id); // Merge content let mergedContent; const clientContent = utils.deepCopy(store.state.content.itemsById[contentId]); if (!clientContent) { mergedContent = utils.deepCopy(serverContent || null); } else if (!serverContent // If sync location has not been created yet // Or server and client contents are synced || serverContent.hash === clientContent.hash // Or server content has not changed or has already been merged || syncedContent.historyData[serverContent.hash] ) { mergedContent = clientContent; } else { // Perform a merge with last merged content if any, or perform a simple fusion otherwise let lastMergedContent = utils.someResult( serverContent.history, hash => syncedContent.historyData[hash], ); if (!lastMergedContent && syncHistoryItem) { lastMergedContent = syncedContent.historyData[syncHistoryItem[LAST_MERGED]]; } mergedContent = diffUtils.mergeContent(serverContent, clientContent, lastMergedContent); } if (!mergedContent) { return; } // Update or set content in store store.commit('content/setItem', { id: contentId, text: utils.sanitizeText(mergedContent.text), properties: utils.sanitizeText(mergedContent.properties), discussions: mergedContent.discussions, comments: mergedContent.comments, }); // Retrieve content with its new hash value and freeze it mergedContent = utils.deepCopy(store.state.content.itemsById[contentId]); // Make merged content history const mergedContentHistory = serverContent ? serverContent.history.slice() : []; let skipUpload = true; if (mergedContentHistory[0] !== mergedContent.hash) { // Put merged content hash at the beginning of history mergedContentHistory.unshift(mergedContent.hash); // Server content is either out of sync or its history is incomplete, do upload skipUpload = false; } if (syncHistoryItem && syncHistoryItem[LAST_SENT] != null && syncHistoryItem[LAST_SENT] !== mergedContent.hash ) { // Clean up by removing the hash we've previously added const idx = mergedContentHistory.lastIndexOf(syncHistoryItem[LAST_SENT]); if (idx !== -1) { mergedContentHistory.splice(idx, 1); } } // Update synced content const newSyncedContent = utils.deepCopy(syncedContent); const newSyncHistoryItem = newSyncedContent.syncHistory[syncLocation.id] || []; newSyncedContent.syncHistory[syncLocation.id] = newSyncHistoryItem; if (serverContent && (serverContent.hash === newSyncHistoryItem[LAST_SEEN] || serverContent.history.includes(newSyncHistoryItem[LAST_SEEN])) ) { // That's the 2nd time we've seen this content, trust it for future merges newSyncHistoryItem[LAST_MERGED] = newSyncHistoryItem[LAST_SEEN]; } newSyncHistoryItem[LAST_MERGED] = newSyncHistoryItem[LAST_MERGED] || null; newSyncHistoryItem[LAST_SEEN] = mergedContent.hash; newSyncHistoryItem[LAST_SENT] = skipUpload ? null : mergedContent.hash; newSyncedContent.historyData[mergedContent.hash] = mergedContent; // Clean synced content from unused revisions cleanSyncedContent(newSyncedContent); // Store synced content store.commit('syncedContent/patchItem', newSyncedContent); if (skipUpload) { // Server content and merged content are equal, skip content upload return; } // If content is to be created, schedule a restart to create the file as well if (provider === workspaceProvider && !store.getters['data/syncDataByItemId'][fileId] ) { syncContext.restartSkipContents = true; } // Upload merged content const item = { ...mergedContent, history: mergedContentHistory.slice(0, maxContentHistory), }; const syncLocationToStore = await uploadContent( item, tooLateChecker(restartContentSyncAfter), ); // Replace sync location if modified if (utils.serializeObject(syncLocation) !== utils.serializeObject(syncLocationToStore) ) { store.commit('syncLocation/patchItem', syncLocationToStore); workspaceSvc.ensureUniqueLocations(); } }; await store.dispatch('queue/doWithLocation', { location: syncLocation, action: async () => { try { await doSyncLocation(); } catch (err) { if (store.state.offline || (err && err.message === 'TOO_LATE')) { throw err; } console.error(err); // eslint-disable-line no-console store.dispatch('notification/error', err); } }, }); }); } catch (err) { if (err && err.message === 'TOO_LATE') { // Restart sync await syncFile(fileId, syncContext); } else { throw err; } } finally { await localDbSvc.unloadContents(); } }; /** * Sync a data item, typically settings, templates or workspaces. */ const syncDataItem = async (dataId) => { const getItem = () => store.state.data.itemsById[dataId] || store.state.data.lsItemsById[dataId]; const oldItem = getItem(); const oldSyncData = store.getters['data/syncDataByItemId'][dataId]; // Sync if item hash and syncData hash are out of sync if (oldSyncData && oldItem && oldItem.hash === oldSyncData.hash) { return; } const token = workspaceProvider.getToken(); const { item } = updateSyncData(await workspaceProvider.downloadWorkspaceData({ token, syncData: oldSyncData, })); const serverItem = item; const dataSyncData = store.getters['data/dataSyncDataById'][dataId]; const clientItem = utils.deepCopy(getItem()); let mergedItem = (() => { if (!clientItem) { return serverItem; } if (!serverItem) { return clientItem; } if (!dataSyncData) { return serverItem; } if (dataSyncData.hash !== serverItem.hash) { // Server version has changed if (dataSyncData.hash !== clientItem.hash && typeof clientItem.data === 'object') { // Client version has changed as well, merge data objects return { ...clientItem, data: diffUtils.mergeObjects(serverItem.data, clientItem.data), }; } return serverItem; } return clientItem; })(); if (!mergedItem) { return; } if (clientItem && dataId === 'workspaces') { // Clean deleted workspaces await Promise.all(Object.keys(clientItem.data) .filter(id => !mergedItem.data[id]) .map(id => workspaceSvc.removeWorkspace(id))); } // Update item in store store.commit('data/setItem', { id: dataId, ...mergedItem, }); // Retrieve item with new `hash` and freeze it mergedItem = utils.deepCopy(getItem()); // Upload merged data item if out of sync if (!serverItem || serverItem.hash !== mergedItem.hash) { updateSyncData(await workspaceProvider.uploadWorkspaceData({ token, item: mergedItem, syncData: store.getters['data/syncDataByItemId'][dataId], ifNotTooLate: tooLateChecker(restartContentSyncAfter), })); } // Copy sync data into data sync data store.dispatch('data/patchDataSyncDataById', { [dataId]: utils.deepCopy(store.getters['data/syncDataByItemId'][dataId]), }); }; /** * Sync the whole workspace with the main provider and the current file explicit locations. */ const syncWorkspace = async (skipContents = false) => { try { const workspace = store.getters['workspace/currentWorkspace']; const syncContext = new SyncContext(); // Store the sub in the DB since it's not safely stored in the token const syncToken = store.getters['workspace/syncToken']; const localSettings = store.getters['data/localSettings']; if (!localSettings.syncSub) { store.dispatch('data/patchLocalSettings', { syncSub: syncToken.sub, }); } else if (localSettings.syncSub !== syncToken.sub) { throw new Error('Synchronization failed due to token inconsistency.'); } const changes = await workspaceProvider.getChanges(); // Apply changes applyChanges(workspaceProvider.prepareChanges(changes)); workspaceProvider.onChangesApplied(); // Prevent from sending items too long after changes have been retrieved const ifNotTooLate = tooLateChecker(restartSyncAfter); // Find and save one item to save await utils.awaitSome(() => ifNotTooLate(async () => { const storeItemMap = { ...store.state.file.itemsById, ...store.state.folder.itemsById, ...store.state.syncLocation.itemsById, ...store.state.publishLocation.itemsById, // Deal with contents and data later }; const syncDataByItemId = store.getters['data/syncDataByItemId']; const isGit = !!store.getters['workspace/currentWorkspaceIsGit']; const [changedItem, syncDataToUpdate] = utils.someResult( Object.entries(storeItemMap), ([id, item]) => { const syncData = syncDataByItemId[id]; if ((syncData && syncData.hash === item.hash) // Add file/folder only if parent folder has been added || (!isGit && storeItemMap[item.parentId] && !syncDataByItemId[item.parentId]) // Don't create folder if it's a git workspace || (isGit && item.type === 'folder') // Add file only if content has been added || (item.type === 'file' && !syncDataByItemId[`${id}/content`]) ) { return null; } return [item, syncData]; }, ) || []; if (!changedItem) return false; updateSyncData(await workspaceProvider.saveWorkspaceItem({ // Use deepCopy to freeze objects item: utils.deepCopy(changedItem), syncData: utils.deepCopy(syncDataToUpdate), ifNotTooLate, })); return true; })); // Find and remove one item to remove await utils.awaitSome(() => ifNotTooLate(async () => { let getItem; let getFileItem; if (store.getters['workspace/currentWorkspaceIsGit']) { const { itemsByGitPath } = store.getters; getItem = syncData => itemsByGitPath[syncData.id]; getFileItem = syncData => itemsByGitPath[syncData.id.slice(1)]; // Remove leading / } else { const { allItemsById } = store.getters; getItem = syncData => allItemsById[syncData.itemId]; getFileItem = syncData => allItemsById[syncData.itemId.split('/')[0]]; } const syncDataById = store.getters['data/syncDataById']; const syncDataToRemove = utils.deepCopy(utils.someResult( Object.values(syncDataById), (syncData) => { if (getItem(syncData) // We don't want to delete data items, especially on first sync || syncData.type === 'data' // Remove content only if file has been removed || (syncData.type === 'content' && getFileItem(syncData)) ) { return null; } return syncData; }, )); if (!syncDataToRemove) return false; await workspaceProvider.removeWorkspaceItem({ syncData: syncDataToRemove, ifNotTooLate, }); const syncDataByIdCopy = { ...store.getters['data/syncDataById'] }; delete syncDataByIdCopy[syncDataToRemove.id]; store.dispatch('data/setSyncDataById', syncDataByIdCopy); return true; })); // Sync settings, workspaces and badges only in the main workspace if (workspace.id === 'main') { await syncDataItem('settings'); await syncDataItem('workspaces'); await syncDataItem('badgeCreations'); } await syncDataItem('templates'); if (!skipContents) { const currentFileId = store.getters['file/current'].id; if (currentFileId) { // Sync current file first await syncFile(currentFileId, syncContext); } // Find and sync one file out of sync await utils.awaitSome(async () => { let getSyncData; if (store.getters['workspace/currentWorkspaceIsGit']) { const { gitPathsByItemId } = store.getters; const syncDataById = store.getters['data/syncDataById']; getSyncData = contentId => syncDataById[gitPathsByItemId[contentId]]; } else { const syncDataByItemId = store.getters['data/syncDataByItemId']; getSyncData = contentId => syncDataByItemId[contentId]; } // Collect all [fileId, contentId] const ids = [ ...Object.keys(localDbSvc.hashMap.content) .map(contentId => [contentId.split('/')[0], contentId]), ...store.getters['file/items'] .map(file => [file.id, `${file.id}/content`]), ]; // Find the first content out of sync const contentMap = store.state.content.itemsById; const fileIdToSync = utils.someResult(ids, ([fileId, contentId]) => { // Get the content hash from itemsById or from localDbSvc if not loaded const loadedContent = contentMap[contentId]; const hash = loadedContent ? loadedContent.hash : localDbSvc.hashMap.content[contentId]; const syncData = getSyncData(contentId); if ( // Sync if content syncing was not attempted yet !syncContext.attempted[contentId] && // And if syncData does not exist or if content hash and syncData hash are inconsistent (!syncData || syncData.hash !== hash) ) { return fileId; } return null; }); if (!fileIdToSync) return false; await syncFile(fileIdToSync, syncContext); return true; }); } // Restart sync if requested if (syncContext.restartSkipContents) { await syncWorkspace(true); } if (workspace.id === 'main') { badgeSvc.addBadge('syncMainWorkspace'); } } catch (err) { if (err && err.message === 'TOO_LATE') { // Restart sync await syncWorkspace(); } else { throw err; } } }; /** * Enqueue a sync task, if possible. */ const requestSync = (addTriggerSyncBadge = false) => { // No sync in light mode if (store.state.light) { return; } store.dispatch('queue/enqueueSyncRequest', async () => { let intervalId; const attempt = async () => { // Only start syncing when these conditions are met if (networkSvc.isUserActive() && isSyncWindow()) { clearInterval(intervalId); if (!isSyncPossible()) { // Cancel sync throw new Error('Sync not possible.'); } // Determine if we have to clean files const fileHashesToClean = {}; if (getLastStoredSyncActivity() + constants.cleanTrashAfter < Date.now()) { // Last synchronization happened 7 days ago const syncDataByItemId = store.getters['data/syncDataByItemId']; store.getters['file/items'].forEach((file) => { // If file is in the trash and has not been modified since it was last synced const syncData = syncDataByItemId[file.id]; if (syncData && file.parentId === 'trash' && file.hash === syncData.hash) { fileHashesToClean[file.id] = file.hash; } }); } // Call setLastSyncActivity periodically intervalId = utils.setInterval(() => setLastSyncActivity(), 1000); setLastSyncActivity(); try { if (isWorkspaceSyncPossible()) { await syncWorkspace(); } else if (hasCurrentFileSyncLocations()) { // Only sync the current file if workspace sync is unavailable // as we don't want to look for out-of-sync files by loading // all the syncedContent objects. await syncFile(store.getters['file/current'].id); } // Clean files Object.entries(fileHashesToClean).forEach(([fileId, fileHash]) => { const file = store.state.file.itemsById[fileId]; if (file && file.hash === fileHash) { workspaceSvc.deleteFile(fileId); } }); if (addTriggerSyncBadge) { badgeSvc.addBadge('triggerSync'); } } finally { clearInterval(intervalId); } } }; intervalId = utils.setInterval(() => attempt(), 1000); return attempt(); }); }; export default { async init() { // Load workspaces and tokens from localStorage localDbSvc.syncLocalStorage(); // Try to find a suitable action provider actionProvider = providerRegistry.providersById[utils.queryParams.providerId]; if (actionProvider && actionProvider.initAction) { await actionProvider.initAction(); } // Try to find a suitable workspace sync provider workspaceProvider = providerRegistry.providersById[utils.queryParams.providerId]; if (!workspaceProvider || !workspaceProvider.initWorkspace) { workspaceProvider = googleDriveAppDataProvider; } const workspace = await workspaceProvider.initWorkspace(); // Fix the URL hash const { paymentSuccess } = utils.queryParams; utils.setQueryParams(workspaceProvider.getWorkspaceParams(workspace)); store.dispatch('workspace/setCurrentWorkspaceId', workspace.id); await localDbSvc.init(); // Enable sponsorship if (paymentSuccess) { store.dispatch('modal/open', 'paymentSuccess') .catch(() => { /* Cancel */ }); const sponsorToken = store.getters['workspace/sponsorToken']; // Force check sponsorship after a few seconds const currentDate = Date.now(); if (sponsorToken && sponsorToken.expiresOn > currentDate - checkSponsorshipAfter) { store.dispatch('data/addGoogleToken', { ...sponsorToken, expiresOn: currentDate - checkSponsorshipAfter, }); } } // Try to find a suitable action provider actionProvider = providerRegistry.providersById[utils.queryParams.providerId] || actionProvider; if (actionProvider && actionProvider.performAction) { const newSyncLocation = await actionProvider.performAction(); if (newSyncLocation) { this.createSyncLocation(newSyncLocation); } } await tempFileSvc.init(); if (!store.state.light) { // Sync periodically utils.setInterval(() => { if (isSyncPossible() && networkSvc.isUserActive() && isSyncWindow() && isAutoSyncReady() ) { requestSync(); } }, 1000); // Unload contents from memory periodically utils.setInterval(() => { // Wait for sync and publish to finish if (store.state.queue.isEmpty) { localDbSvc.unloadContents(); } }, 5000); } }, isSyncPossible, requestSync, createSyncLocation, }; ================================================ FILE: src/services/tempFileSvc.js ================================================ import cledit from './editor/cledit'; import store from '../store'; import utils from './utils'; import editorSvc from './editorSvc'; import workspaceSvc from './workspaceSvc'; const { origin, fileName, contentText, contentProperties, } = utils.queryParams; const isLight = origin && window.parent; export default { setReady() { if (isLight) { // Wait for the editor to init setTimeout(() => window.parent.postMessage({ type: 'ready' }, origin), 1); } }, closed: false, close() { if (isLight) { if (!this.closed) { window.parent.postMessage({ type: 'close' }, origin); } this.closed = true; } }, async init() { if (!isLight) { return; } store.commit('setLight', true); const file = await workspaceSvc.createFile({ name: fileName || utils.getHostname(origin), text: contentText || '\n', properties: contentProperties, parentId: 'temp', }, true); // Sanitize file creations const lastCreated = {}; const fileItemsById = store.state.file.itemsById; Object.entries(store.getters['data/lastCreated']).forEach(([id, value]) => { if (fileItemsById[id] && fileItemsById[id].parentId === 'temp') { lastCreated[id] = value; } }); // Track file creation from other site lastCreated[file.id] = { created: Date.now(), }; // Keep only the last 10 temp files created by other sites Object.entries(lastCreated) .sort(([, value1], [, value2]) => value2.created - value1.created) .splice(10) .forEach(([id]) => { delete lastCreated[id]; workspaceSvc.deleteFile(id); }); // Store file creations and open the file store.dispatch('data/setLastCreated', lastCreated); store.commit('file/setCurrentId', file.id); const onChange = cledit.Utils.debounce(() => { const currentFile = store.getters['file/current']; if (currentFile.id !== file.id) { // Close editor if file has changed for some reason this.close(); } else if (!this.closed && editorSvc.previewCtx.html != null) { const content = store.getters['content/current']; const properties = utils.computeProperties(content.properties); window.parent.postMessage({ type: 'fileChange', payload: { id: file.id, name: currentFile.name, content: { text: content.text.slice(0, -1), // Remove trailing LF properties, yamlProperties: content.properties, html: editorSvc.previewCtx.html, }, }, }, origin); } }, 25); // Watch preview refresh and file name changes editorSvc.$on('previewCtx', onChange); store.watch(() => store.getters['file/current'].name, onChange); }, }; ================================================ FILE: src/services/templateWorker.js ================================================ // This WebWorker provides a safe environment to run user scripts // See http://stackoverflow.com/questions/10653809/making-webworkers-a-safe-environment/10796616 import Handlebars from 'handlebars'; // Classeur own helpers Handlebars.registerHelper('tocToHtml', (toc, depth = 6) => { function arrayToHtml(arr) { if (!arr || !arr.length || arr[0].level > depth) { return ''; } const ulHtml = arr.map((item) => { let result = '
    • '; if (item.anchor && item.title) { result += `${item.title}`; } result += arrayToHtml(item.children); return `${result}
    • `; }).join('\n'); return `\n
        \n${ulHtml}\n
      \n`; } return new Handlebars.SafeString(arrayToHtml(toc)); }); const whiteList = { self: 1, onmessage: 1, postMessage: 1, global: 1, whiteList: 1, eval: 1, Array: 1, Boolean: 1, Date: 1, Function: 1, Number: 1, Object: 1, RegExp: 1, String: 1, Error: 1, EvalError: 1, RangeError: 1, ReferenceError: 1, SyntaxError: 1, TypeError: 1, URIError: 1, decodeURI: 1, decodeURIComponent: 1, encodeURI: 1, encodeURIComponent: 1, isFinite: 1, isNaN: 1, parseFloat: 1, parseInt: 1, Infinity: 1, JSON: 1, Math: 1, NaN: 1, undefined: 1, safeEval: 1, close: 1, }; /* eslint-disable no-restricted-globals */ let global = self; while (global !== Object.prototype) { Object.getOwnPropertyNames(global).forEach((prop) => { // eslint-disable-line no-loop-func if (!Object.prototype.hasOwnProperty.call(whiteList, prop)) { try { Object.defineProperty(global, prop, { get() { throw new Error(`Security Exception: cannot access ${prop}`); }, configurable: false, }); } catch (e) { // Ignore } } }); global = Object.getPrototypeOf(global); } self.Handlebars = Handlebars; function safeEval(code) { eval(`"use strict";\n${code}`); // eslint-disable-line no-eval } self.onmessage = (evt) => { try { const template = Handlebars.compile(evt.data[0]); const context = evt.data[1]; safeEval(evt.data[2]); self.postMessage([null, template(context)]); } catch (err) { self.postMessage([`${err}`]); } close(); }; ================================================ FILE: src/services/timeSvc.js ================================================ // Credit: https://github.com/github/time-elements/ const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; const pad = num => `0${num}`.slice(-2); function strftime(time, formatString) { const day = time.getDay(); const date = time.getDate(); const month = time.getMonth(); const year = time.getFullYear(); const hour = time.getHours(); const minute = time.getMinutes(); const second = time.getSeconds(); return formatString.replace(/%([%aAbBcdeHIlmMpPSwyYZz])/g, (_arg) => { let match; const modifier = _arg[1]; switch (modifier) { case '%': default: return '%'; case 'a': return weekdays[day].slice(0, 3); case 'A': return weekdays[day]; case 'b': return months[month].slice(0, 3); case 'B': return months[month]; case 'c': return time.toString(); case 'd': return pad(date); case 'e': return date; case 'H': return pad(hour); case 'I': return pad(strftime(time, '%l')); case 'l': return hour === 0 || hour === 12 ? 12 : (hour + 12) % 12; case 'm': return pad(month + 1); case 'M': return pad(minute); case 'p': return hour > 11 ? 'PM' : 'AM'; case 'P': return hour > 11 ? 'pm' : 'am'; case 'S': return pad(second); case 'w': return day; case 'y': return pad(year % 100); case 'Y': return year; case 'Z': match = time.toString().match(/\((\w+)\)$/); return match ? match[1] : ''; case 'z': match = time.toString().match(/\w([+-]\d\d\d\d) /); return match ? match[1] : ''; } }); } let dayFirst = null; let yearSeparator = null; // Private: Determine if the day should be formatted before the month name in // the user's current locale. For example, `9 Jun` for en-GB and `Jun 9` // for en-US. // // Returns true if the day appears before the month. function isDayFirst() { if (dayFirst !== null) { return dayFirst; } if (!('Intl' in window)) { return false; } const options = { day: 'numeric', month: 'short' }; const formatter = new window.Intl.DateTimeFormat(undefined, options); const output = formatter.format(new Date(0)); dayFirst = !!output.match(/^\d/); return dayFirst; } // Private: Determine if the year should be separated from the month and day // with a comma. For example, `9 Jun 2014` in en-GB and `Jun 9, 2014` in en-US. // // Returns true if the date needs a separator. function isYearSeparator() { if (yearSeparator !== null) { return yearSeparator; } if (!('Intl' in window)) { return true; } const options = { day: 'numeric', month: 'short', year: 'numeric' }; const formatter = new window.Intl.DateTimeFormat(undefined, options); const output = formatter.format(new Date(0)); yearSeparator = !!output.match(/\d,/); return yearSeparator; } // Private: Determine if the date occurs in the same year as today's date. // // date - The Date to test. // // Returns true if it's this year. function isThisYear(date) { const now = new Date(); return now.getUTCFullYear() === date.getUTCFullYear(); } class RelativeTime { constructor(date) { this.date = date; } toString() { const ago = this.timeElapsed(); return ago || `on ${this.formatDate()}`; } timeElapsed() { const ms = new Date().getTime() - this.date.getTime(); const sec = Math.round(ms / 1000); const min = Math.round(sec / 60); const hr = Math.round(min / 60); const day = Math.round(hr / 24); if (ms < 0) { return 'just now'; } else if (sec < 45) { return 'just now'; } else if (sec < 90) { return 'a minute ago'; } else if (min < 45) { return `${min} minutes ago`; } else if (min < 90) { return 'an hour ago'; } else if (hr < 24) { return `${hr} hours ago`; } else if (hr < 36) { return 'a day ago'; } else if (day < 30) { return `${day} days ago`; } return null; } formatDate() { let format = isDayFirst() ? '%e %b' : '%b %e'; if (!isThisYear(this.date)) { format += isYearSeparator() ? ', %Y' : ' %Y'; } return strftime(this.date, format); } } export default { format(time) { return time && new RelativeTime(new Date(time)).toString(); }, }; ================================================ FILE: src/services/userSvc.js ================================================ import store from '../store'; import utils from './utils'; const refreshUserInfoAfter = 60 * 60 * 1000; // 60 minutes const infoResolversByType = {}; const subPrefixesByType = {}; const typesBySubPrefix = {}; const lastInfosByUserId = {}; const infoPromisedByUserId = {}; const sanitizeUserId = (userId) => { const prefix = userId[2] === ':' && userId.slice(0, 2); if (typesBySubPrefix[prefix]) { return userId; } return `go:${userId}`; }; const parseUserId = userId => [typesBySubPrefix[userId.slice(0, 2)], userId.slice(3)]; const refreshUserInfos = () => { if (store.state.offline) { return; } Object.entries(lastInfosByUserId) .filter(([userId, lastInfo]) => lastInfo === 0 && !infoPromisedByUserId[userId]) .forEach(async ([userId]) => { const [type, sub] = parseUserId(userId); const infoResolver = infoResolversByType[type]; if (infoResolver) { try { infoPromisedByUserId[userId] = true; const userInfo = await infoResolver(sub); store.commit('userInfo/setItem', userInfo); } finally { infoPromisedByUserId[userId] = false; lastInfosByUserId[userId] = Date.now(); } } }); }; export default { setInfoResolver(type, subPrefix, resolver) { infoResolversByType[type] = resolver; subPrefixesByType[type] = subPrefix; typesBySubPrefix[subPrefix] = type; }, getCurrentUserId() { const loginToken = store.getters['workspace/loginToken']; if (!loginToken) { return null; } const loginType = store.getters['workspace/loginType']; const prefix = subPrefixesByType[loginType]; return prefix ? `${prefix}:${loginToken.sub}` : loginToken.sub; }, sanitizeUserId, addUserInfo(userInfo) { store.commit('userInfo/setItem', userInfo); lastInfosByUserId[userInfo.id] = Date.now(); }, addUserId(userId) { if (userId) { const sanitizedUserId = sanitizeUserId(userId); const lastInfo = lastInfosByUserId[sanitizedUserId]; if (lastInfo === undefined) { // Try to find a token with this sub to resolve name as soon as possible const [type, sub] = parseUserId(sanitizedUserId); const token = store.getters['data/tokensByType'][type][sub]; if (token) { store.commit('userInfo/setItem', { id: sanitizedUserId, name: token.name, }); } } if (lastInfo === undefined || lastInfo + refreshUserInfoAfter < Date.now()) { lastInfosByUserId[sanitizedUserId] = 0; refreshUserInfos(); } } }, }; // Get user info periodically utils.setInterval(() => refreshUserInfos(), 60 * 1000); ================================================ FILE: src/services/utils.js ================================================ import yaml from 'js-yaml'; import '../libs/clunderscore'; import presets from '../data/presets'; import constants from '../data/constants'; // For utils.uid() const uidLength = 16; const crypto = window.crypto || window.msCrypto; const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); const radix = alphabet.length; const array = new Uint32Array(uidLength); // For utils.parseQueryParams() const parseQueryParams = (params) => { const result = {}; params.split('&').forEach((param) => { const [key, value] = param.split('=').map(decodeURIComponent); if (key && value != null) { result[key] = value; } }); return result; }; // For utils.setQueryParams() const filterParams = (params = {}) => { const result = {}; Object.entries(params).forEach(([key, value]) => { if (key && value != null) { result[key] = value; } }); return result; }; // For utils.computeProperties() const deepOverride = (obj, opt) => { if (obj === undefined) { return opt; } const objType = Object.prototype.toString.call(obj); const optType = Object.prototype.toString.call(opt); if (objType !== optType) { return obj; } if (objType !== '[object Object]') { return opt === undefined ? obj : opt; } Object.keys({ ...obj, ...opt, }).forEach((key) => { obj[key] = deepOverride(obj[key], opt[key]); }); return obj; }; // For utils.addQueryParams() const urlParser = document.createElement('a'); const deepCopy = (obj) => { if (obj == null) { return obj; } return JSON.parse(JSON.stringify(obj)); }; // Compute presets const computedPresets = {}; Object.keys(presets).forEach((key) => { let preset = deepCopy(presets[key][0]); if (presets[key][1]) { preset = deepOverride(preset, presets[key][1]); } computedPresets[key] = preset; }); export default { computedPresets, queryParams: parseQueryParams(window.location.hash.slice(1)), setQueryParams(params = {}) { this.queryParams = filterParams(params); const serializedParams = Object.entries(this.queryParams).map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&'); const hash = `#${serializedParams}`; if (window.location.hash !== hash) { window.location.replace(hash); } }, sanitizeText(text) { const result = `${text || ''}`.slice(0, constants.textMaxLength); // last char must be a `\n`. return `${result}\n`.replace(/\n\n$/, '\n'); }, sanitizeName(name) { return `${name || ''}` // Keep only 250 characters .slice(0, 250) || constants.defaultName; }, sanitizeFilename(name) { return this.sanitizeName(`${name || ''}` // Replace `/`, control characters and other kind of spaces with a space .replace(/[/\x00-\x1F\x7f-\xa0\s]+/g, ' ') // eslint-disable-line no-control-regex .trim()) || constants.defaultName; }, deepCopy, serializeObject(obj) { return obj === undefined ? obj : JSON.stringify(obj, (key, value) => { if (Object.prototype.toString.call(value) !== '[object Object]') { return value; } // Sort keys to have a predictable result return Object.keys(value).sort().reduce((sorted, valueKey) => { sorted[valueKey] = value[valueKey]; return sorted; }, {}); }); }, search(items, criteria) { let result; items.some((item) => { // If every field fits the criteria if (Object.entries(criteria).every(([key, value]) => value === item[key])) { result = item; } return result; }); return result; }, uid() { crypto.getRandomValues(array); return array.cl_map(value => alphabet[value % radix]).join(''); }, hash(str) { // https://stackoverflow.com/a/7616484/1333165 let hash = 0; if (!str) return hash; for (let i = 0; i < str.length; i += 1) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; // eslint-disable-line no-bitwise hash |= 0; // eslint-disable-line no-bitwise } return hash; }, getItemHash(item) { return this.hash(this.serializeObject({ ...item, // These properties must not be part of the hash id: undefined, hash: undefined, history: undefined, })); }, addItemHash(item) { return { ...item, hash: this.getItemHash(item), }; }, makeWorkspaceId(params) { return Math.abs(this.hash(this.serializeObject(params))).toString(36); }, getDbName(workspaceId) { let dbName = 'stackedit-db'; if (workspaceId !== 'main') { dbName += `-${workspaceId}`; } return dbName; }, encodeBase64(str, urlSafe = false) { const uriEncodedStr = encodeURIComponent(str); const utf8Str = uriEncodedStr.replace( /%([0-9A-F]{2})/g, (match, p1) => String.fromCharCode(`0x${p1}`), ); const result = btoa(utf8Str); if (!urlSafe) { return result; } return result .replace(/\//g, '_') // Replace `/` with `_` .replace(/\+/g, '-') // Replace `+` with `-` .replace(/=+$/, ''); // Remove trailing `=` }, decodeBase64(str) { // In case of URL safe base64 const sanitizedStr = str.replace(/_/g, '/').replace(/-/g, '+'); const utf8Str = atob(sanitizedStr); const uriEncodedStr = utf8Str .split('') .map(c => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`) .join(''); return decodeURIComponent(uriEncodedStr); }, computeProperties(yamlProperties) { let properties = {}; try { properties = yaml.safeLoad(yamlProperties) || {}; } catch (e) { // Ignore } const extensions = properties.extensions || {}; const computedPreset = deepCopy(computedPresets[extensions.preset] || computedPresets.default); const computedExtensions = deepOverride(computedPreset, properties.extensions); computedExtensions.preset = extensions.preset; properties.extensions = computedExtensions; return properties; }, randomize(value) { return Math.floor((1 + (Math.random() * 0.2)) * value); }, setInterval(func, interval) { return setInterval(() => func(), this.randomize(interval)); }, async awaitSequence(values, asyncFunc) { const results = []; const valuesLeft = values.slice().reverse(); const runWithNextValue = async () => { if (!valuesLeft.length) { return results; } results.push(await asyncFunc(valuesLeft.pop())); return runWithNextValue(); }; return runWithNextValue(); }, async awaitSome(asyncFunc) { if (await asyncFunc()) { return this.awaitSome(asyncFunc); } return null; }, someResult(values, func) { let result; values.some((value) => { result = func(value); return result; }); return result; }, parseQueryParams, addQueryParams(url = '', params = {}, hash = false) { const keys = Object.keys(params).filter(key => params[key] != null); urlParser.href = url; if (!keys.length) { return urlParser.href; } const serializedParams = keys.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`).join('&'); if (hash) { if (urlParser.hash) { urlParser.hash += '&'; } else { urlParser.hash = '#'; } urlParser.hash += serializedParams; } else { if (urlParser.search) { urlParser.search += '&'; } else { urlParser.search = '?'; } urlParser.search += serializedParams; } return urlParser.href; }, resolveUrl(baseUrl, path) { const oldBaseElt = document.getElementsByTagName('base')[0]; const oldHref = oldBaseElt && oldBaseElt.href; const newBaseElt = oldBaseElt || document.head.appendChild(document.createElement('base')); newBaseElt.href = baseUrl; urlParser.href = path; const result = urlParser.href; if (oldBaseElt) { oldBaseElt.href = oldHref; } else { document.head.removeChild(newBaseElt); } return result; }, getHostname(url) { urlParser.href = url; return urlParser.hostname; }, encodeUrlPath(path) { return path ? path.split('/').map(encodeURIComponent).join('/') : ''; }, parseGithubRepoUrl(url) { const parsedRepo = url && url.match(/([^/:]+)\/([^/]+?)(?:\.git|\/)?$/); return parsedRepo && { owner: parsedRepo[1], repo: parsedRepo[2], }; }, parseGitlabProjectPath(url) { const parsedProject = url && url.match(/^https:\/\/[^/]+\/(.+?)(?:\.git|\/)?$/); return parsedProject && parsedProject[1]; }, createHiddenIframe(url) { const iframeElt = document.createElement('iframe'); iframeElt.style.position = 'absolute'; iframeElt.style.left = '-99px'; iframeElt.style.width = '1px'; iframeElt.style.height = '1px'; iframeElt.src = url; return iframeElt; }, wrapRange(range, eltProperties) { const rangeLength = `${range}`.length; let wrappedLength = 0; const treeWalker = document .createTreeWalker(range.commonAncestorContainer, NodeFilter.SHOW_TEXT); let { startOffset } = range; treeWalker.currentNode = range.startContainer; if (treeWalker.currentNode.nodeType === Node.TEXT_NODE || treeWalker.nextNode()) { do { if (treeWalker.currentNode.nodeValue !== '\n') { if (treeWalker.currentNode === range.endContainer && range.endOffset < treeWalker.currentNode.nodeValue.length ) { treeWalker.currentNode.splitText(range.endOffset); } if (startOffset) { treeWalker.currentNode = treeWalker.currentNode.splitText(startOffset); startOffset = 0; } const elt = document.createElement('span'); Object.entries(eltProperties).forEach(([key, value]) => { elt[key] = value; }); treeWalker.currentNode.parentNode.insertBefore(elt, treeWalker.currentNode); elt.appendChild(treeWalker.currentNode); } wrappedLength += treeWalker.currentNode.nodeValue.length; if (wrappedLength >= rangeLength) { break; } } while (treeWalker.nextNode()); } }, unwrapRange(eltCollection) { Array.prototype.slice.call(eltCollection).forEach((elt) => { // Loop in case another wrapper has been added inside for (let child = elt.firstChild; child; child = elt.firstChild) { if (child.nodeType === 3) { if (elt.previousSibling && elt.previousSibling.nodeType === 3) { child.nodeValue = elt.previousSibling.nodeValue + child.nodeValue; elt.parentNode.removeChild(elt.previousSibling); } if (!child.nextSibling && elt.nextSibling && elt.nextSibling.nodeType === 3) { child.nodeValue += elt.nextSibling.nodeValue; elt.parentNode.removeChild(elt.nextSibling); } } elt.parentNode.insertBefore(child, elt); } elt.parentNode.removeChild(elt); }); }, }; ================================================ FILE: src/services/workspaceSvc.js ================================================ import store from '../store'; import utils from './utils'; import constants from '../data/constants'; import badgeSvc from './badgeSvc'; const forbiddenFolderNameMatcher = /^\.stackedit-data$|^\.stackedit-trash$|\.md$|\.sync$|\.publish$/; export default { /** * Create a file in the store with the specified fields. */ async createFile({ name, parentId, text, properties, discussions, comments, } = {}, background = false) { const id = utils.uid(); const item = { id, name: utils.sanitizeFilename(name), parentId: parentId || null, }; const content = { id: `${id}/content`, text: utils.sanitizeText(text || store.getters['data/computedSettings'].newFileContent), properties: utils .sanitizeText(properties || store.getters['data/computedSettings'].newFileProperties), discussions: discussions || {}, comments: comments || {}, }; const workspaceUniquePaths = store.getters['workspace/currentWorkspaceHasUniquePaths']; // Show warning dialogs if (!background) { // If name is being stripped if (item.name !== constants.defaultName && item.name !== name) { await store.dispatch('modal/open', { type: 'stripName', item, }); } // Check if there is already a file with that path if (workspaceUniquePaths) { const parentPath = store.getters.pathsByItemId[item.parentId] || ''; const path = parentPath + item.name; if (store.getters.itemsByPath[path]) { await store.dispatch('modal/open', { type: 'pathConflict', item, }); } } } // Save file and content in the store store.commit('content/setItem', content); store.commit('file/setItem', item); if (workspaceUniquePaths) { this.makePathUnique(id); } // Return the new file item return store.state.file.itemsById[id]; }, /** * Make sanity checks and then create/update the folder/file in the store. */ async storeItem(item) { const id = item.id || utils.uid(); const sanitizedName = utils.sanitizeFilename(item.name); if (item.type === 'folder' && forbiddenFolderNameMatcher.exec(sanitizedName)) { await store.dispatch('modal/open', { type: 'unauthorizedName', item, }); throw new Error('Unauthorized name.'); } // Show warning dialogs // If name has been stripped if (sanitizedName !== constants.defaultName && sanitizedName !== item.name) { await store.dispatch('modal/open', { type: 'stripName', item, }); } // Check if there is a path conflict if (store.getters['workspace/currentWorkspaceHasUniquePaths']) { const parentPath = store.getters.pathsByItemId[item.parentId] || ''; const path = parentPath + sanitizedName; const items = store.getters.itemsByPath[path] || []; if (items.some(itemWithSamePath => itemWithSamePath.id !== id)) { await store.dispatch('modal/open', { type: 'pathConflict', item, }); } } return this.setOrPatchItem({ ...item, id, }); }, /** * Create/update the folder/file in the store and make sure its path is unique. */ setOrPatchItem(patch) { const item = { ...store.getters.allItemsById[patch.id] || patch, }; if (!item.id) { return null; } if (patch.parentId !== undefined) { item.parentId = patch.parentId || null; } if (patch.name) { const sanitizedName = utils.sanitizeFilename(patch.name); if (item.type !== 'folder' || !forbiddenFolderNameMatcher.exec(sanitizedName)) { item.name = sanitizedName; } } // Save item in the store store.commit(`${item.type}/setItem`, item); // Remove circular reference this.removeCircularReference(item); // Ensure path uniqueness if (store.getters['workspace/currentWorkspaceHasUniquePaths']) { this.makePathUnique(item.id); } return store.getters.allItemsById[item.id]; }, /** * Delete a file in the store and all its related items. */ deleteFile(fileId) { // Delete the file store.commit('file/deleteItem', fileId); // Delete the content store.commit('content/deleteItem', `${fileId}/content`); // Delete the syncedContent store.commit('syncedContent/deleteItem', `${fileId}/syncedContent`); // Delete the contentState store.commit('contentState/deleteItem', `${fileId}/contentState`); // Delete sync locations (store.getters['syncLocation/groupedByFileId'][fileId] || []) .forEach(item => store.commit('syncLocation/deleteItem', item.id)); // Delete publish locations (store.getters['publishLocation/groupedByFileId'][fileId] || []) .forEach(item => store.commit('publishLocation/deleteItem', item.id)); }, /** * Sanitize the whole workspace. */ sanitizeWorkspace(idsToKeep) { // Detect and remove circular references for all folders. store.getters['folder/items'].forEach(folder => this.removeCircularReference(folder)); this.ensureUniquePaths(idsToKeep); this.ensureUniqueLocations(idsToKeep); }, /** * Detect and remove circular reference for an item. */ removeCircularReference(item) { const foldersById = store.state.folder.itemsById; for ( let parentFolder = foldersById[item.parentId]; parentFolder; parentFolder = foldersById[parentFolder.parentId] ) { if (parentFolder.id === item.id) { store.commit('folder/patchItem', { id: item.id, parentId: null, }); break; } } }, /** * Ensure two files/folders don't have the same path if the workspace doesn't allow it. */ ensureUniquePaths(idsToKeep = {}) { if (store.getters['workspace/currentWorkspaceHasUniquePaths']) { if (Object.keys(store.getters.pathsByItemId) .some(id => !idsToKeep[id] && this.makePathUnique(id)) ) { // Just changed one item path, restart this.ensureUniquePaths(idsToKeep); } } }, /** * Return false if the file/folder path is unique. * Add a prefix to its name and return true otherwise. */ makePathUnique(id) { const { itemsByPath, allItemsById, pathsByItemId } = store.getters; const item = allItemsById[id]; if (!item) { return false; } let path = pathsByItemId[id]; if (itemsByPath[path].length === 1) { return false; } const isFolder = item.type === 'folder'; if (isFolder) { // Remove trailing slash path = path.slice(0, -1); } for (let suffix = 1; ; suffix += 1) { let pathWithSuffix = `${path}.${suffix}`; if (isFolder) { pathWithSuffix += '/'; } if (!itemsByPath[pathWithSuffix]) { store.commit(`${item.type}/patchItem`, { id: item.id, name: `${item.name}.${suffix}`, }); return true; } } }, addSyncLocation(location) { store.commit('syncLocation/setItem', { ...location, id: utils.uid(), }); // Sanitize the workspace this.ensureUniqueLocations(); if (Object.keys(store.getters['syncLocation/currentWithWorkspaceSyncLocation']).length > 1) { badgeSvc.addBadge('syncMultipleLocations'); } }, addPublishLocation(location) { store.commit('publishLocation/setItem', { ...location, id: utils.uid(), }); // Sanitize the workspace this.ensureUniqueLocations(); if (Object.keys(store.getters['publishLocation/current']).length > 1) { badgeSvc.addBadge('publishMultipleLocations'); } }, /** * Ensure two sync/publish locations of the same file don't have the same hash. */ ensureUniqueLocations(idsToKeep = {}) { ['syncLocation', 'publishLocation'].forEach((type) => { store.getters[`${type}/items`].forEach((item) => { if (!idsToKeep[item.id] && store.getters[`${type}/groupedByFileIdAndHash`][item.fileId][item.hash].length > 1 ) { store.commit(`${item.type}/deleteItem`, item.id); } }); }); }, /** * Drop the database and clean the localStorage for the specified workspaceId. */ async removeWorkspace(id) { // Remove from the store first as workspace tabs will reload. // Workspace deletion will be persisted as soon as possible // by the store.getters['data/workspaces'] watcher in localDbSvc. store.dispatch('workspace/removeWorkspace', id); // Drop the database await new Promise((resolve) => { const dbName = utils.getDbName(id); const request = indexedDB.deleteDatabase(dbName); request.onerror = resolve; // Ignore errors request.onsuccess = resolve; }); // Clean the local storage localStorage.removeItem(`${id}/lastSyncActivity`); localStorage.removeItem(`${id}/lastWindowFocus`); }, }; ================================================ FILE: src/store/content.js ================================================ import DiffMatchPatch from 'diff-match-patch'; import moduleTemplate from './moduleTemplate'; import empty from '../data/empties/emptyContent'; import utils from '../services/utils'; import cledit from '../services/editor/cledit'; import badgeSvc from '../services/badgeSvc'; const diffMatchPatch = new DiffMatchPatch(); const module = moduleTemplate(empty); module.state = { ...module.state, revisionContent: null, }; module.mutations = { ...module.mutations, setRevisionContent: (state, value) => { if (value) { state.revisionContent = { ...empty(), ...value, id: utils.uid(), hash: Date.now(), }; } else { state.revisionContent = null; } }, }; module.getters = { ...module.getters, current: ({ itemsById, revisionContent }, getters, rootState, rootGetters) => { if (revisionContent) { return revisionContent; } return itemsById[`${rootGetters['file/current'].id}/content`] || empty(); }, currentChangeTrigger: (state, getters) => { const { current } = getters; return utils.serializeObject([ current.id, current.text, current.hash, ]); }, currentProperties: (state, { current }) => utils.computeProperties(current.properties), isCurrentEditable: ({ revisionContent }, { current }, rootState, rootGetters) => !revisionContent && current.id && rootGetters['layout/styles'].showEditor, }; module.actions = { ...module.actions, patchCurrent({ state, getters, commit }, value) { const { id } = getters.current; if (id && !state.revisionContent) { commit('patchItem', { ...value, id, }); } }, setRevisionContent({ state, rootGetters, commit }, value) { const currentFile = rootGetters['file/current']; const currentContent = state.itemsById[`${currentFile.id}/content`]; if (currentContent) { const diffs = diffMatchPatch.diff_main(currentContent.text, value.text); diffMatchPatch.diff_cleanupSemantic(diffs); commit('setRevisionContent', { text: diffs.map(([, text]) => text).join(''), diffs, originalText: value.text, }); } }, async restoreRevision({ state, getters, commit, dispatch, }) { const { revisionContent } = state; if (revisionContent) { await dispatch('modal/open', 'fileRestoration', { root: true }); // Close revision commit('setRevisionContent'); const currentContent = utils.deepCopy(getters.current); if (currentContent) { // Restore text and move discussions const diffs = diffMatchPatch .diff_main(currentContent.text, revisionContent.originalText); diffMatchPatch.diff_cleanupSemantic(diffs); Object.entries(currentContent.discussions).forEach(([, discussion]) => { const adjustOffset = (offsetName) => { const marker = new cledit.Marker(discussion[offsetName], offsetName === 'end'); marker.adjustOffset(diffs); discussion[offsetName] = marker.offset; }; adjustOffset('start'); adjustOffset('end'); }); dispatch('patchCurrent', { ...currentContent, text: revisionContent.originalText, }); badgeSvc.addBadge('restoreVersion'); } } }, }; export default module; ================================================ FILE: src/store/contentState.js ================================================ import moduleTemplate from './moduleTemplate'; import empty from '../data/empties/emptyContentState'; const module = moduleTemplate(empty, true); module.getters = { ...module.getters, current: ({ itemsById }, getters, rootState, rootGetters) => itemsById[`${rootGetters['file/current'].id}/contentState`] || empty(), }; module.actions = { ...module.actions, patchCurrent({ getters, commit }, value) { commit('patchItem', { ...value, id: getters.current.id, }); }, }; export default module; ================================================ FILE: src/store/contextMenu.js ================================================ const setter = propertyName => (state, value) => { state[propertyName] = value; }; export default { namespaced: true, state: { coordinates: { left: 0, top: 0, }, items: [], resolve: () => {}, }, mutations: { setCoordinates: setter('coordinates'), setItems: setter('items'), setResolve: setter('resolve'), }, actions: { open({ commit, rootState }, { coordinates, items }) { commit('setItems', items); // Place the context menu outside the screen commit('setCoordinates', { top: 0, left: -9999 }); // Let the UI refresh itself setTimeout(() => { // Take the size of the context menu and place it const elt = document.querySelector('.context-menu__inner'); if (elt) { const height = elt.offsetHeight; if (coordinates.top + height > rootState.layout.bodyHeight) { coordinates.top -= height; } if (coordinates.top < 0) { coordinates.top = 0; } const width = elt.offsetWidth; if (coordinates.left + width > rootState.layout.bodyWidth) { coordinates.left -= width; } if (coordinates.left < 0) { coordinates.left = 0; } commit('setCoordinates', coordinates); } }, 1); return new Promise(resolve => commit('setResolve', resolve)); }, close({ commit }) { commit('setItems', []); commit('setResolve', () => {}); }, }, }; ================================================ FILE: src/store/data.js ================================================ import Vue from 'vue'; import yaml from 'js-yaml'; import utils from '../services/utils'; import defaultWorkspaces from '../data/defaults/defaultWorkspaces'; import defaultSettings from '../data/defaults/defaultSettings.yml'; import defaultLocalSettings from '../data/defaults/defaultLocalSettings'; import defaultLayoutSettings from '../data/defaults/defaultLayoutSettings'; import plainHtmlTemplate from '../data/templates/plainHtmlTemplate.html'; import styledHtmlTemplate from '../data/templates/styledHtmlTemplate.html'; import styledHtmlWithTocTemplate from '../data/templates/styledHtmlWithTocTemplate.html'; import jekyllSiteTemplate from '../data/templates/jekyllSiteTemplate.html'; import constants from '../data/constants'; import features from '../data/features'; import badgeSvc from '../services/badgeSvc'; const itemTemplate = (id, data = {}) => ({ id, type: 'data', data, hash: 0, }); const empty = (id) => { switch (id) { case 'workspaces': return itemTemplate(id, defaultWorkspaces()); case 'settings': return itemTemplate(id, '\n'); case 'localSettings': return itemTemplate(id, defaultLocalSettings()); case 'layoutSettings': return itemTemplate(id, defaultLayoutSettings()); default: return itemTemplate(id); } }; // Item IDs that will be stored in the localStorage const localStorageIdSet = new Set(constants.localStorageDataIds); // Getter/setter/patcher factories const getter = id => (state) => { const itemsById = localStorageIdSet.has(id) ? state.lsItemsById : state.itemsById; if (itemsById[id]) { return itemsById[id].data; } return empty(id).data; }; const setter = id => ({ commit }, data) => commit('setItem', itemTemplate(id, data)); const patcher = id => ({ state, commit }, data) => { const itemsById = localStorageIdSet.has(id) ? state.lsItemsById : state.itemsById; const item = Object.assign(empty(id), itemsById[id]); commit('setItem', { ...empty(id), data: typeof data === 'object' ? { ...item.data, ...data, } : data, }); }; // For layoutSettings const toggleLayoutSetting = (name, value, featureId, getters, dispatch) => { const currentValue = getters.layoutSettings[name]; const patch = { [name]: value === undefined ? !currentValue : !!value, }; if (patch[name] !== currentValue) { dispatch('patchLayoutSettings', patch); badgeSvc.addBadge(featureId); } }; const layoutSettingsToggler = (propertyName, featureId) => ({ getters, dispatch }, value) => toggleLayoutSetting(propertyName, value, featureId, getters, dispatch); const notEnoughSpace = (layoutConstants, showGutter) => document.body.clientWidth < layoutConstants.editorMinWidth + layoutConstants.explorerWidth + layoutConstants.sideBarWidth + layoutConstants.buttonBarWidth + (showGutter ? layoutConstants.gutterWidth : 0); // For templates const makeAdditionalTemplate = (name, value, helpers = '\n') => ({ name, value, helpers, isAdditional: true, }); const defaultTemplates = { plainText: makeAdditionalTemplate('Plain text', '{{{files.0.content.text}}}'), plainHtml: makeAdditionalTemplate('Plain HTML', plainHtmlTemplate), styledHtml: makeAdditionalTemplate('Styled HTML', styledHtmlTemplate), styledHtmlWithToc: makeAdditionalTemplate('Styled HTML with TOC', styledHtmlWithTocTemplate), jekyllSite: makeAdditionalTemplate('Jekyll site', jekyllSiteTemplate), }; // For tokens const tokenAdder = providerId => ({ getters, dispatch }, token) => { dispatch('patchTokensByType', { [providerId]: { ...getters[`${providerId}TokensBySub`], [token.sub]: token, }, }); }; export default { namespaced: true, state: { // Data items stored in the DB itemsById: {}, // Data items stored in the localStorage lsItemsById: {}, }, mutations: { setItem: ({ itemsById, lsItemsById }, value) => { // Create an empty item and override its data field const emptyItem = empty(value.id); const data = typeof value.data === 'object' ? Object.assign(emptyItem.data, value.data) : value.data; // Make item with hash const item = utils.addItemHash({ ...emptyItem, data, }); // Store item in itemsById or lsItemsById if its stored in the localStorage Vue.set(localStorageIdSet.has(item.id) ? lsItemsById : itemsById, item.id, item); }, deleteItem({ itemsById }, id) { // Only used by localDbSvc to clean itemsById from object moved to localStorage Vue.delete(itemsById, id); }, }, getters: { serverConf: getter('serverConf'), workspaces: getter('workspaces'), // Not to be used, prefer workspace/workspacesById settings: getter('settings'), computedSettings: (state, { settings }) => { const customSettings = yaml.safeLoad(settings); const parsedSettings = yaml.safeLoad(defaultSettings); const override = (obj, opt) => { const objType = Object.prototype.toString.call(obj); const optType = Object.prototype.toString.call(opt); if (objType !== optType) { return obj; } else if (objType !== '[object Object]') { return opt; } Object.keys(obj).forEach((key) => { if (key === 'shortcuts') { obj[key] = Object.assign(obj[key], opt[key]); } else { obj[key] = override(obj[key], opt[key]); } }); return obj; }; return override(parsedSettings, customSettings); }, localSettings: getter('localSettings'), layoutSettings: getter('layoutSettings'), templatesById: getter('templates'), allTemplatesById: (state, { templatesById }) => ({ ...templatesById, ...defaultTemplates, }), lastCreated: getter('lastCreated'), lastOpened: getter('lastOpened'), lastOpenedIds: (state, { lastOpened }, rootState) => { const result = { ...lastOpened, }; const currentFileId = rootState.file.currentId; if (currentFileId && !result[currentFileId]) { result[currentFileId] = Date.now(); } return Object.keys(result) .filter(id => rootState.file.itemsById[id]) .sort((id1, id2) => result[id2] - result[id1]) .slice(0, 20); }, syncDataById: getter('syncData'), syncDataByItemId: (state, { syncDataById }, rootState, rootGetters) => { const result = {}; if (rootGetters['workspace/currentWorkspaceIsGit']) { Object.entries(rootGetters.gitPathsByItemId).forEach(([id, path]) => { const syncDataEntry = syncDataById[path]; if (syncDataEntry) { result[id] = syncDataEntry; } }); } else { Object.entries(syncDataById).forEach(([, syncDataEntry]) => { result[syncDataEntry.itemId] = syncDataEntry; }); } return result; }, dataSyncDataById: getter('dataSyncData'), tokensByType: getter('tokens'), googleTokensBySub: (state, { tokensByType }) => tokensByType.google || {}, couchdbTokensBySub: (state, { tokensByType }) => tokensByType.couchdb || {}, dropboxTokensBySub: (state, { tokensByType }) => tokensByType.dropbox || {}, githubTokensBySub: (state, { tokensByType }) => tokensByType.github || {}, gitlabTokensBySub: (state, { tokensByType }) => tokensByType.gitlab || {}, wordpressTokensBySub: (state, { tokensByType }) => tokensByType.wordpress || {}, zendeskTokensBySub: (state, { tokensByType }) => tokensByType.zendesk || {}, badgeCreations: getter('badgeCreations'), badgeTree: (state, { badgeCreations }) => features .map(feature => feature.toBadge(badgeCreations)), allBadges: (state, { badgeTree }) => { const result = []; const processBadgeNodes = nodes => nodes.forEach((node) => { result.push(node); if (node.children) { processBadgeNodes(node.children); } }); processBadgeNodes(badgeTree); return result; }, }, actions: { setServerConf: setter('serverConf'), setSettings: setter('settings'), patchLocalSettings: patcher('localSettings'), patchLayoutSettings: patcher('layoutSettings'), toggleNavigationBar: layoutSettingsToggler('showNavigationBar', 'toggleNavigationBar'), toggleEditor: layoutSettingsToggler('showEditor', 'toggleEditor'), toggleSidePreview: layoutSettingsToggler('showSidePreview', 'toggleSidePreview'), toggleStatusBar: layoutSettingsToggler('showStatusBar', 'toggleStatusBar'), toggleScrollSync: layoutSettingsToggler('scrollSync', 'toggleScrollSync'), toggleFocusMode: layoutSettingsToggler('focusMode', 'toggleFocusMode'), toggleSideBar: ({ getters, dispatch, rootGetters }, value) => { // Reset side bar dispatch('setSideBarPanel'); // Toggle it toggleLayoutSetting('showSideBar', value, 'toggleSideBar', getters, dispatch); // Close explorer if not enough space if (getters.layoutSettings.showSideBar && notEnoughSpace(rootGetters['layout/constants'], rootGetters['discussion/currentDiscussion']) ) { dispatch('patchLayoutSettings', { showExplorer: false, }); } }, toggleExplorer: ({ getters, dispatch, rootGetters }, value) => { // Toggle explorer toggleLayoutSetting('showExplorer', value, 'toggleExplorer', getters, dispatch); // Close side bar if not enough space if (getters.layoutSettings.showExplorer && notEnoughSpace(rootGetters['layout/constants'], rootGetters['discussion/currentDiscussion']) ) { dispatch('patchLayoutSettings', { showSideBar: false, }); } }, setSideBarPanel: ({ dispatch }, value) => dispatch('patchLayoutSettings', { sideBarPanel: value === undefined ? 'menu' : value, }), setTemplatesById: ({ commit }, templatesById) => { const templatesToCommit = { ...templatesById, }; // We don't store additional templates Object.keys(defaultTemplates).forEach((id) => { delete templatesToCommit[id]; }); commit('setItem', itemTemplate('templates', templatesToCommit)); }, setLastCreated: setter('lastCreated'), setLastOpenedId: ({ getters, commit, rootState }, fileId) => { const lastOpened = { ...getters.lastOpened }; lastOpened[fileId] = Date.now(); // Remove entries that don't exist anymore const cleanedLastOpened = {}; Object.entries(lastOpened).forEach(([id, value]) => { if (rootState.file.itemsById[id]) { cleanedLastOpened[id] = value; } }); commit('setItem', itemTemplate('lastOpened', cleanedLastOpened)); }, setSyncDataById: setter('syncData'), patchSyncDataById: patcher('syncData'), patchDataSyncDataById: patcher('dataSyncData'), patchTokensByType: patcher('tokens'), addGoogleToken: tokenAdder('google'), addCouchdbToken: tokenAdder('couchdb'), addDropboxToken: tokenAdder('dropbox'), addGithubToken: tokenAdder('github'), addGitlabToken: tokenAdder('gitlab'), addWordpressToken: tokenAdder('wordpress'), addZendeskToken: tokenAdder('zendesk'), patchBadgeCreations: patcher('badgeCreations'), }, }; ================================================ FILE: src/store/discussion.js ================================================ import utils from '../services/utils'; import googleHelper from '../services/providers/helpers/googleHelper'; import syncSvc from '../services/syncSvc'; const idShifter = offset => (state, getters) => { const ids = Object.keys(getters.currentFileDiscussions) .filter(id => id !== state.newDiscussionId); const idx = ids.indexOf(state.currentDiscussionId) + offset + ids.length; return ids[idx % ids.length]; }; export default { namespaced: true, state: { currentDiscussionId: null, newDiscussion: null, newDiscussionId: null, isCommenting: false, newCommentText: '', newCommentSelection: { start: 0, end: 0 }, newCommentFocus: false, stickyComment: null, }, mutations: { setCurrentDiscussionId: (state, value) => { if (state.currentDiscussionId !== value) { state.currentDiscussionId = value; state.isCommenting = false; } }, setNewDiscussion: (state, value) => { state.newDiscussion = value; state.newDiscussionId = utils.uid(); state.currentDiscussionId = state.newDiscussionId; state.isCommenting = true; state.newCommentFocus = true; }, patchNewDiscussion: (state, value) => { Object.assign(state.newDiscussion, value); }, setIsCommenting: (state, value) => { state.isCommenting = value; if (!value) { state.newDiscussionId = null; } else { state.newCommentFocus = true; } }, setNewCommentText: (state, value) => { state.newCommentText = value || ''; }, setNewCommentSelection: (state, value) => { state.newCommentSelection = value; }, setNewCommentFocus: (state, value) => { state.newCommentFocus = value; }, setStickyComment: (state, value) => { state.stickyComment = value; }, }, getters: { newDiscussion: ({ currentDiscussionId, newDiscussionId, newDiscussion }) => currentDiscussionId === newDiscussionId && newDiscussion, currentFileDiscussionLastComments: (state, getters, rootState, rootGetters) => { const { discussions, comments } = rootGetters['content/current']; const discussionLastComments = {}; Object.entries(comments).forEach(([, comment]) => { if (discussions[comment.discussionId]) { const lastComment = discussionLastComments[comment.discussionId]; if (!lastComment || lastComment.created < comment.created) { discussionLastComments[comment.discussionId] = comment; } } }); return discussionLastComments; }, currentFileDiscussions: ( { newDiscussionId }, { newDiscussion, currentFileDiscussionLastComments }, rootState, rootGetters, ) => { const currentFileDiscussions = {}; if (newDiscussion) { currentFileDiscussions[newDiscussionId] = newDiscussion; } const { discussions } = rootGetters['content/current']; Object.entries(currentFileDiscussionLastComments) .sort(([, lastComment1], [, lastComment2]) => lastComment1.created - lastComment2.created) .forEach(([discussionId]) => { currentFileDiscussions[discussionId] = discussions[discussionId]; }); return currentFileDiscussions; }, currentDiscussion: ({ currentDiscussionId }, { currentFileDiscussions }) => currentFileDiscussions[currentDiscussionId], previousDiscussionId: idShifter(-1), nextDiscussionId: idShifter(1), currentDiscussionComments: ( { currentDiscussionId }, { currentDiscussion }, rootState, rootGetters, ) => { const comments = {}; if (currentDiscussion) { const contentComments = rootGetters['content/current'].comments; Object.entries(contentComments) .filter(([, comment]) => comment.discussionId === currentDiscussionId) .sort(([, comment1], [, comment2]) => comment1.created - comment2.created) .forEach(([commentId, comment]) => { comments[commentId] = comment; }); } return comments; }, currentDiscussionLastCommentId: (state, { currentDiscussionComments }) => Object.keys(currentDiscussionComments).pop(), currentDiscussionLastComment: ( state, { currentDiscussionComments, currentDiscussionLastCommentId }, ) => currentDiscussionComments[currentDiscussionLastCommentId], }, actions: { cancelNewComment({ commit, getters }) { commit('setIsCommenting', false); if (!getters.currentDiscussion) { commit('setCurrentDiscussionId', getters.nextDiscussionId); } }, async createNewDiscussion({ commit, dispatch, rootGetters }, selection) { const loginToken = rootGetters['workspace/loginToken']; if (!loginToken) { try { await dispatch('modal/open', 'signInForComment', { root: true }); await googleHelper.signin(); syncSvc.requestSync(); await dispatch('createNewDiscussion', selection); } catch (e) { /* cancel */ } } else if (selection) { let text = rootGetters['content/current'].text.slice(selection.start, selection.end).trim(); const maxLength = 80; if (text.length > maxLength) { text = `${text.slice(0, maxLength - 1).trim()}…`; } commit('setNewDiscussion', { ...selection, text }); } }, cleanCurrentFile({ getters, rootGetters, commit, dispatch, }, { filterComment, filterDiscussion } = {}) { const { discussions } = rootGetters['content/current']; const { comments } = rootGetters['content/current']; const patch = { discussions: {}, comments: {}, }; Object.entries(comments).forEach(([commentId, comment]) => { const discussion = discussions[comment.discussionId]; if (discussion && comment !== filterComment && discussion !== filterDiscussion) { patch.discussions[comment.discussionId] = discussion; patch.comments[commentId] = comment; } }); const { nextDiscussionId } = getters; dispatch('content/patchCurrent', patch, { root: true }); if (!getters.currentDiscussion) { // Keep the gutter open commit('setCurrentDiscussionId', nextDiscussionId); } }, }, }; ================================================ FILE: src/store/explorer.js ================================================ import Vue from 'vue'; import emptyFile from '../data/empties/emptyFile'; import emptyFolder from '../data/empties/emptyFolder'; const setter = propertyName => (state, value) => { state[propertyName] = value; }; function debounceAction(action, wait) { let timeoutId; return (context) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => action(context), wait); }; } const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }); const compare = (node1, node2) => collator.compare(node1.item.name, node2.item.name); class Node { constructor(item, locations = [], isFolder = false) { this.item = item; this.locations = locations; this.isFolder = isFolder; if (isFolder) { this.folders = []; this.files = []; } } sortChildren() { if (this.isFolder) { this.folders.sort(compare); this.files.sort(compare); this.folders.forEach(child => child.sortChildren()); } } } const nilFileNode = new Node(emptyFile()); nilFileNode.isNil = true; const fakeFileNode = new Node(emptyFile()); fakeFileNode.item.id = 'fake'; fakeFileNode.noDrag = true; function getParent({ item, isNil }, { nodeMap, rootNode }) { if (isNil) { return nilFileNode; } return nodeMap[item.parentId] || rootNode; } function getFolder(node, getters) { return node.item.type === 'folder' ? node : getParent(node, getters); } export default { namespaced: true, state: { selectedId: null, editingId: null, dragSourceId: null, dragTargetId: null, newChildNode: nilFileNode, openNodes: {}, }, mutations: { setSelectedId: setter('selectedId'), setEditingId: setter('editingId'), setDragSourceId: setter('dragSourceId'), setDragTargetId: setter('dragTargetId'), setNewItem(state, item) { state.newChildNode = item ? new Node(item, [], item.type === 'folder') : nilFileNode; }, setNewItemName(state, name) { state.newChildNode.item.name = name; }, toggleOpenNode(state, id) { Vue.set(state.openNodes, id, !state.openNodes[id]); }, }, getters: { nodeStructure: (state, getters, rootState, rootGetters) => { const rootNode = new Node(emptyFolder(), [], true); rootNode.isRoot = true; // Create Trash node const trashFolderNode = new Node(emptyFolder(), [], true); trashFolderNode.item.id = 'trash'; trashFolderNode.item.name = 'Trash'; trashFolderNode.noDrag = true; trashFolderNode.isTrash = true; trashFolderNode.parentNode = rootNode; // Create Temp node const tempFolderNode = new Node(emptyFolder(), [], true); tempFolderNode.item.id = 'temp'; tempFolderNode.item.name = 'Temp'; tempFolderNode.noDrag = true; tempFolderNode.noDrop = true; tempFolderNode.isTemp = true; tempFolderNode.parentNode = rootNode; // Fill nodeMap with all file and folder nodes const nodeMap = { trash: trashFolderNode, temp: tempFolderNode, }; rootGetters['folder/items'].forEach((item) => { nodeMap[item.id] = new Node(item, [], true); }); const syncLocationsByFileId = rootGetters['syncLocation/filteredGroupedByFileId']; const publishLocationsByFileId = rootGetters['publishLocation/filteredGroupedByFileId']; rootGetters['file/items'].forEach((item) => { const locations = [ ...syncLocationsByFileId[item.id] || [], ...publishLocationsByFileId[item.id] || [], ]; nodeMap[item.id] = new Node(item, locations); }); // Build the tree Object.entries(nodeMap).forEach(([, node]) => { let parentNode = nodeMap[node.item.parentId]; if (!parentNode || !parentNode.isFolder) { if (node.isTrash || node.isTemp) { return; } parentNode = rootNode; } if (node.isFolder) { parentNode.folders.push(node); } else { parentNode.files.push(node); } node.parentNode = parentNode; }); rootNode.sortChildren(); // Add Trash and Temp nodes rootNode.folders.unshift(tempFolderNode); tempFolderNode.files.forEach((node) => { node.noDrop = true; }); rootNode.folders.unshift(trashFolderNode); // Add a fake file at the end of the root folder to allow drag and drop into it rootNode.files.push(fakeFileNode); return { nodeMap, rootNode, }; }, nodeMap: (state, { nodeStructure }) => nodeStructure.nodeMap, rootNode: (state, { nodeStructure }) => nodeStructure.rootNode, newChildNodeParent: (state, getters) => getParent(state.newChildNode, getters), selectedNode: ({ selectedId }, { nodeMap }) => nodeMap[selectedId] || nilFileNode, selectedNodeFolder: (state, getters) => getFolder(getters.selectedNode, getters), editingNode: ({ editingId }, { nodeMap }) => nodeMap[editingId] || nilFileNode, dragSourceNode: ({ dragSourceId }, { nodeMap }) => nodeMap[dragSourceId] || nilFileNode, dragTargetNode: ({ dragTargetId }, { nodeMap }) => { if (dragTargetId === 'fake') { return fakeFileNode; } return nodeMap[dragTargetId] || nilFileNode; }, dragTargetNodeFolder: ({ dragTargetId }, getters) => { if (dragTargetId === 'fake') { return getters.rootNode; } return getFolder(getters.dragTargetNode, getters); }, }, actions: { openNode({ state, getters, commit, dispatch, }, id) { const node = getters.nodeMap[id]; if (node) { if (node.isFolder && !state.openNodes[id]) { commit('toggleOpenNode', id); } dispatch('openNode', node.item.parentId); } }, openDragTarget: debounceAction(({ state, dispatch }) => { dispatch('openNode', state.dragTargetId); }, 1000), setDragTarget({ commit, getters, dispatch }, node) { if (!node) { commit('setDragTargetId'); } else { // Make sure target node is not a child of source node const folderNode = getFolder(node, getters); const sourceId = getters.dragSourceNode.item.id; const { nodeMap } = getters; for (let parentNode = folderNode; parentNode; parentNode = nodeMap[parentNode.item.parentId] ) { if (parentNode.item.id === sourceId) { commit('setDragTargetId'); return; } } commit('setDragTargetId', node.item.id); dispatch('openDragTarget'); } }, }, }; ================================================ FILE: src/store/file.js ================================================ import moduleTemplate from './moduleTemplate'; import empty from '../data/empties/emptyFile'; const module = moduleTemplate(empty); module.state = { ...module.state, currentId: null, }; module.getters = { ...module.getters, current: ({ itemsById, currentId }) => itemsById[currentId] || empty(), isCurrentTemp: (state, { current }) => current.parentId === 'temp', lastOpened: ({ itemsById }, { items }, rootState, rootGetters) => itemsById[rootGetters['data/lastOpenedIds'][0]] || items[0] || empty(), }; module.mutations = { ...module.mutations, setCurrentId(state, value) { state.currentId = value; }, }; module.actions = { ...module.actions, patchCurrent({ getters, commit }, value) { commit('patchItem', { ...value, id: getters.current.id, }); }, }; export default module; ================================================ FILE: src/store/findReplace.js ================================================ export default { namespaced: true, state: { type: null, lastOpen: 0, findText: '', replaceText: '', }, mutations: { setType: (state, value) => { state.type = value; }, setLastOpen: (state) => { state.lastOpen = Date.now(); }, setFindText: (state, value) => { state.findText = value; }, setReplaceText: (state, value) => { state.replaceText = value; }, }, actions: { open({ commit }, { type, findText }) { commit('setType', type); if (findText) { commit('setFindText', findText); } commit('setLastOpen'); }, }, }; ================================================ FILE: src/store/folder.js ================================================ import moduleTemplate from './moduleTemplate'; import empty from '../data/empties/emptyFolder'; const module = moduleTemplate(empty); export default module; ================================================ FILE: src/store/index.js ================================================ import createLogger from 'vuex/dist/logger'; import Vue from 'vue'; import Vuex from 'vuex'; import utils from '../services/utils'; import content from './content'; import contentState from './contentState'; import contextMenu from './contextMenu'; import data from './data'; import discussion from './discussion'; import explorer from './explorer'; import file from './file'; import findReplace from './findReplace'; import folder from './folder'; import layout from './layout'; import modal from './modal'; import notification from './notification'; import queue from './queue'; import syncedContent from './syncedContent'; import userInfo from './userInfo'; import workspace from './workspace'; import locationTemplate from './locationTemplate'; import emptyPublishLocation from '../data/empties/emptyPublishLocation'; import emptySyncLocation from '../data/empties/emptySyncLocation'; import constants from '../data/constants'; Vue.use(Vuex); const debug = NODE_ENV !== 'production'; const store = new Vuex.Store({ modules: { content, contentState, contextMenu, data, discussion, explorer, file, findReplace, folder, layout, modal, notification, publishLocation: locationTemplate(emptyPublishLocation), queue, syncedContent, syncLocation: locationTemplate(emptySyncLocation), userInfo, workspace, }, state: { light: false, offline: false, lastOfflineCheck: 0, timeCounter: 0, }, mutations: { setLight: (state, value) => { state.light = value; }, setOffline: (state, value) => { state.offline = value; }, updateLastOfflineCheck: (state) => { state.lastOfflineCheck = Date.now(); }, updateTimeCounter: (state) => { state.timeCounter += 1; }, }, getters: { allItemsById: (state) => { const result = {}; constants.types.forEach(type => Object.assign(result, state[type].itemsById)); return result; }, pathsByItemId: (state, getters) => { const result = {}; const processNode = (node, parentPath = '') => { let path = parentPath; if (node.item.id) { path += node.item.name; if (node.isTrash) { path = '.stackedit-trash/'; } else if (node.isFolder) { path += '/'; } result[node.item.id] = path; } if (node.isFolder) { node.folders.forEach(child => processNode(child, path)); node.files.forEach(child => processNode(child, path)); } }; processNode(getters['explorer/rootNode']); return result; }, itemsByPath: (state, { allItemsById, pathsByItemId }) => { const result = {}; Object.entries(pathsByItemId).forEach(([id, path]) => { const items = result[path] || []; items.push(allItemsById[id]); result[path] = items; }); return result; }, gitPathsByItemId: (state, { allItemsById, pathsByItemId }) => { const result = {}; Object.entries(allItemsById).forEach(([id, item]) => { if (item.type === 'data') { result[id] = `.stackedit-data/${id}.json`; } else if (item.type === 'file') { const filePath = pathsByItemId[id]; result[id] = `${filePath}.md`; result[`${id}/content`] = `/${filePath}.md`; } else if (item.type === 'content') { const [fileId] = id.split('/'); const filePath = pathsByItemId[fileId]; result[fileId] = `${filePath}.md`; result[id] = `/${filePath}.md`; } else if (item.type === 'folder') { result[id] = pathsByItemId[id]; } else if (item.type === 'syncLocation' || item.type === 'publishLocation') { // locations are stored as paths const encodedItem = utils.encodeBase64(utils.serializeObject({ ...item, id: undefined, type: undefined, fileId: undefined, hash: undefined, }), true); const extension = item.type === 'syncLocation' ? 'sync' : 'publish'; result[id] = `${pathsByItemId[item.fileId]}.${encodedItem}.${extension}`; } }); return result; }, itemIdsByGitPath: (state, { gitPathsByItemId }) => { const result = {}; Object.entries(gitPathsByItemId).forEach(([id, path]) => { result[path] = id; }); return result; }, itemsByGitPath: (state, { allItemsById, gitPathsByItemId }) => { const result = {}; Object.entries(gitPathsByItemId).forEach(([id, path]) => { const item = allItemsById[id]; if (item) { result[path] = item; } }); return result; }, isSponsor: ({ light }, getters) => { if (light) { return true; } if (!getters['data/serverConf'].allowSponsorship) { return true; } const sponsorToken = getters['workspace/sponsorToken']; return sponsorToken ? sponsorToken.isSponsor : false; }, }, actions: { setOffline: ({ state, commit, dispatch }, value) => { if (state.offline !== value) { commit('setOffline', value); if (state.offline) { return Promise.reject(new Error('You are offline.')); } dispatch('notification/info', 'You are back online!'); } return Promise.resolve(); }, }, strict: debug, plugins: debug ? [createLogger()] : [], }); setInterval(() => { store.commit('updateTimeCounter'); }, 30 * 1000); export default store; ================================================ FILE: src/store/layout.js ================================================ import pagedownButtons from '../data/pagedownButtons'; let buttonCount = 2; // 2 for undo/redo let spacerCount = 0; pagedownButtons.forEach((button) => { if (button.method) { buttonCount += 1; } else { spacerCount += 1; } }); const minPadding = 25; const editorTopPadding = 10; const navigationBarEditButtonsWidth = (34 * buttonCount) + (8 * spacerCount); // buttons + spacers const navigationBarLeftButtonWidth = 38 + 4 + 12; const navigationBarRightButtonWidth = 38 + 8; const navigationBarSpinnerWidth = 24 + 8 + 5; // 5 for left margin const navigationBarLocationWidth = 20; const navigationBarSyncPublishButtonsWidth = 34 + 10; const navigationBarTitleMargin = 8; const maxTitleMaxWidth = 800; const minTitleMaxWidth = 200; const constants = { editorMinWidth: 320, explorerWidth: 260, gutterWidth: 250, sideBarWidth: 280, navigationBarHeight: 44, buttonBarWidth: 26, statusBarHeight: 20, }; function computeStyles(state, getters, layoutSettings = getters['data/layoutSettings'], styles = { showNavigationBar: layoutSettings.showNavigationBar || !layoutSettings.showEditor || state.content.revisionContent || state.light, showStatusBar: layoutSettings.showStatusBar, showEditor: layoutSettings.showEditor, showSidePreview: layoutSettings.showSidePreview && layoutSettings.showEditor, showPreview: layoutSettings.showSidePreview || !layoutSettings.showEditor, showSideBar: layoutSettings.showSideBar && !state.light, showExplorer: layoutSettings.showExplorer && !state.light, layoutOverflow: false, hideLocations: state.light, }) { styles.innerHeight = state.layout.bodyHeight; if (styles.showNavigationBar) { styles.innerHeight -= constants.navigationBarHeight; } if (styles.showStatusBar) { styles.innerHeight -= constants.statusBarHeight; } styles.innerWidth = state.layout.bodyWidth; if (styles.innerWidth < constants.editorMinWidth + constants.gutterWidth + constants.buttonBarWidth ) { styles.layoutOverflow = true; } if (styles.showSideBar) { styles.innerWidth -= constants.sideBarWidth; } if (styles.showExplorer) { styles.innerWidth -= constants.explorerWidth; } let doublePanelWidth = styles.innerWidth - constants.buttonBarWidth; // No commenting for temp files const showGutter = !getters['file/isCurrentTemp'] && !!getters['discussion/currentDiscussion']; if (showGutter) { doublePanelWidth -= constants.gutterWidth; } if (doublePanelWidth < constants.editorMinWidth) { doublePanelWidth = constants.editorMinWidth; } if (styles.showSidePreview && doublePanelWidth / 2 < constants.editorMinWidth) { styles.showSidePreview = false; styles.showPreview = false; styles.layoutOverflow = false; return computeStyles(state, getters, layoutSettings, styles); } const computedSettings = getters['data/computedSettings']; styles.fontSize = 18; styles.textWidth = 990; if (doublePanelWidth < 1120) { styles.fontSize -= 1; styles.textWidth = 910; } if (doublePanelWidth < 1040) { styles.textWidth = 830; } styles.textWidth *= computedSettings.maxWidthFactor; if (doublePanelWidth < styles.textWidth) { styles.textWidth = doublePanelWidth; } if (styles.textWidth < 640) { styles.fontSize -= 1; } styles.fontSize *= computedSettings.fontSizeFactor; const bottomPadding = Math.floor(styles.innerHeight / 2); const panelWidth = Math.floor(doublePanelWidth / 2); styles.previewWidth = styles.showSidePreview ? panelWidth : doublePanelWidth; const previewRightPadding = Math .max(Math.floor((styles.previewWidth - styles.textWidth) / 2), minPadding); if (!styles.showSidePreview) { styles.previewWidth += constants.buttonBarWidth; } styles.previewGutterWidth = showGutter && !layoutSettings.showEditor ? constants.gutterWidth : 0; const previewLeftPadding = previewRightPadding + styles.previewGutterWidth; styles.previewGutterLeft = previewLeftPadding - minPadding; styles.previewPadding = `${editorTopPadding}px ${previewRightPadding}px ${bottomPadding}px ${previewLeftPadding}px`; styles.editorWidth = styles.showSidePreview ? panelWidth : doublePanelWidth; const editorRightPadding = Math .max(Math.floor((styles.editorWidth - styles.textWidth) / 2), minPadding); styles.editorGutterWidth = showGutter && layoutSettings.showEditor ? constants.gutterWidth : 0; const editorLeftPadding = editorRightPadding + styles.editorGutterWidth; styles.editorGutterLeft = editorLeftPadding - minPadding; styles.editorPadding = `${editorTopPadding}px ${editorRightPadding}px ${bottomPadding}px ${editorLeftPadding}px`; styles.titleMaxWidth = styles.innerWidth - navigationBarLeftButtonWidth - navigationBarRightButtonWidth - navigationBarSpinnerWidth; if (styles.showEditor) { const syncLocations = getters['syncLocation/current']; const publishLocations = getters['publishLocation/current']; styles.titleMaxWidth -= navigationBarEditButtonsWidth + (navigationBarLocationWidth * (syncLocations.length + publishLocations.length)) + (navigationBarSyncPublishButtonsWidth * 2) + navigationBarTitleMargin; if (styles.titleMaxWidth + navigationBarEditButtonsWidth < minTitleMaxWidth) { styles.hideLocations = true; } } styles.titleMaxWidth = Math .max(minTitleMaxWidth, Math .min(maxTitleMaxWidth, styles.titleMaxWidth)); return styles; } export default { namespaced: true, state: { canUndo: false, canRedo: false, bodyWidth: 0, bodyHeight: 0, }, mutations: { setCanUndo: (state, value) => { state.canUndo = value; }, setCanRedo: (state, value) => { state.canRedo = value; }, updateBodySize: (state) => { state.bodyWidth = document.body.clientWidth; state.bodyHeight = document.body.clientHeight; }, }, getters: { constants: () => constants, styles: (state, getters, rootState, rootGetters) => computeStyles(rootState, rootGetters), }, actions: { updateBodySize({ commit, dispatch, rootGetters }) { commit('updateBodySize'); // Make sure both explorer and side bar are not open if body width is small const layoutSettings = rootGetters['data/layoutSettings']; dispatch('data/toggleExplorer', layoutSettings.showExplorer, { root: true }); }, }, }; ================================================ FILE: src/store/locationTemplate.js ================================================ import moduleTemplate from './moduleTemplate'; import providerRegistry from '../services/providers/common/providerRegistry'; import utils from '../services/utils'; const addToGroup = (groups, item) => { const list = groups[item.fileId]; if (!list) { groups[item.fileId] = [item]; } else { list.push(item); } }; export default (empty) => { const module = moduleTemplate(empty); module.getters = { ...module.getters, groupedByFileId: (state, { items }) => { const groups = {}; items.forEach(item => addToGroup(groups, item)); return groups; }, groupedByFileIdAndHash: (state, { items }) => { const fileIdGroups = {}; items.forEach((item) => { let hashGroups = fileIdGroups[item.fileId]; if (!hashGroups) { hashGroups = {}; fileIdGroups[item.fileId] = hashGroups; } const list = hashGroups[item.hash]; if (!list) { hashGroups[item.hash] = [item]; } else { list.push(item); } }); return fileIdGroups; }, filteredGroupedByFileId: (state, { items }) => { const groups = {}; items .filter((item) => { // Filter items that we can't use const provider = providerRegistry.providersById[item.providerId]; return provider && provider.getToken(item); }) .forEach(item => addToGroup(groups, item)); return groups; }, current: (state, { filteredGroupedByFileId }, rootState, rootGetters) => { const locations = filteredGroupedByFileId[rootGetters['file/current'].id] || []; return locations.map((location) => { const provider = providerRegistry.providersById[location.providerId]; return { ...location, description: utils.sanitizeName(provider.getLocationDescription(location)), url: provider.getLocationUrl(location), }; }); }, currentWithWorkspaceSyncLocation: (state, { current }, rootState, rootGetters) => { const fileId = rootGetters['file/current'].id; const fileSyncData = rootGetters['data/syncDataByItemId'][fileId]; const contentSyncData = rootGetters['data/syncDataByItemId'][`${fileId}/content`]; if (!fileSyncData || !contentSyncData) { return current; } // Add the workspace sync location const workspaceProvider = providerRegistry.providersById[ rootGetters['workspace/currentWorkspace'].providerId]; return [{ id: 'main', providerId: workspaceProvider.id, fileId, description: utils.sanitizeName(workspaceProvider .getSyncDataDescription(fileSyncData, contentSyncData)), url: workspaceProvider.getSyncDataUrl(fileSyncData, contentSyncData), }, ...current]; }, }; return module; }; ================================================ FILE: src/store/modal.js ================================================ export default { namespaced: true, state: { stack: [], hidden: false, }, mutations: { setStack: (state, value) => { state.stack = value; }, setHidden: (state, value) => { state.hidden = value; }, }, getters: { config: ({ hidden, stack }) => !hidden && stack[0], }, actions: { async open({ commit, state }, param) { const config = typeof param === 'object' ? { ...param } : { type: param }; try { return await new Promise((resolve, reject) => { config.resolve = resolve; config.reject = reject; commit('setStack', [config, ...state.stack]); }); } finally { commit('setStack', state.stack.filter((otherConfig => otherConfig !== config))); } }, async hideUntil({ commit }, promise) { try { commit('setHidden', true); return await promise; } finally { commit('setHidden', false); } }, }, }; ================================================ FILE: src/store/moduleTemplate.js ================================================ import Vue from 'vue'; import utils from '../services/utils'; export default (empty, simpleHash = false) => { // Use Date.now() as a simple hash function, which is ok for not-synced types const hashFunc = simpleHash ? Date.now : item => utils.getItemHash(item); return { namespaced: true, state: { itemsById: {}, }, getters: { items: ({ itemsById }) => Object.values(itemsById), }, mutations: { setItem(state, value) { const item = Object.assign(empty(value.id), value); if (!item.hash || !simpleHash) { item.hash = hashFunc(item); } Vue.set(state.itemsById, item.id, item); }, patchItem(state, patch) { const item = state.itemsById[patch.id]; if (item) { Object.assign(item, patch); item.hash = hashFunc(item); Vue.set(state.itemsById, item.id, item); return true; } return false; }, deleteItem(state, id) { Vue.delete(state.itemsById, id); }, }, actions: {}, }; }; ================================================ FILE: src/store/notification.js ================================================ import providerRegistry from '../services/providers/common/providerRegistry'; import utils from '../services/utils'; const defaultTimeout = 5000; // 5 sec export default { namespaced: true, state: { items: [], }, mutations: { setItems: (state, value) => { state.items = value; }, }, actions: { showItem({ state, commit }, item) { const existingItem = utils.someResult( state.items, other => other.type === item.type && other.content === item.content && item, ); if (existingItem) { return existingItem.promise; } item.promise = new Promise((resolve, reject) => { commit('setItems', [...state.items, item]); const removeItem = () => commit( 'setItems', state.items.filter(otherItem => otherItem !== item), ); setTimeout( () => removeItem(), item.timeout || defaultTimeout, ); item.resolve = (res) => { removeItem(); resolve(res); }; item.reject = (err) => { removeItem(); reject(err); }; }); return item.promise; }, info({ dispatch }, content) { return dispatch('showItem', { type: 'info', content, }); }, badge({ dispatch }, content) { return dispatch('showItem', { type: 'badge', content, }); }, confirm({ dispatch }, content) { return dispatch('showItem', { type: 'confirm', content, timeout: 10000, // 10 sec }); }, error({ dispatch, rootState }, error) { const item = { type: 'error' }; if (error) { if (error.message) { item.content = error.message; } else if (error.status) { const location = rootState.queue.currentLocation; if (location.providerId) { const provider = providerRegistry.providersById[location.providerId]; item.content = `HTTP error ${error.status} on ${provider.name} location.`; } else { item.content = `HTTP error ${error.status}.`; } } else { item.content = `${error}`; } } if (!item.content || item.content === '[object Object]') { item.content = 'Unknown error.'; } return dispatch('showItem', item); }, }, }; ================================================ FILE: src/store/queue.js ================================================ const setter = propertyName => (state, value) => { state[propertyName] = value; }; let queue = Promise.resolve(); export default { namespaced: true, state: { isEmpty: true, isSyncRequested: false, isPublishRequested: false, currentLocation: {}, }, mutations: { setIsEmpty: setter('isEmpty'), setIsSyncRequested: setter('isSyncRequested'), setIsPublishRequested: setter('isPublishRequested'), setCurrentLocation: setter('currentLocation'), }, actions: { enqueue({ state, commit, dispatch }, cb) { if (state.offline) { // No need to enqueue return; } const checkOffline = () => { if (state.offline) { // Empty queue queue = Promise.resolve(); commit('setIsEmpty', true); throw new Error('offline'); } }; if (state.isEmpty) { commit('setIsEmpty', false); } const newQueue = queue .then(() => checkOffline()) .then(() => Promise.resolve() .then(() => cb()) .catch((err) => { console.error(err); // eslint-disable-line no-console checkOffline(); dispatch('notification/error', err, { root: true }); }) .then(() => { if (newQueue === queue) { commit('setIsEmpty', true); } })); queue = newQueue; }, enqueueSyncRequest({ state, commit, dispatch }, cb) { if (!state.isSyncRequested) { commit('setIsSyncRequested', true); const unset = () => commit('setIsSyncRequested', false); dispatch('enqueue', () => cb().then(unset, (err) => { unset(); throw err; })); } }, enqueuePublishRequest({ state, commit, dispatch }, cb) { if (!state.isSyncRequested) { commit('setIsPublishRequested', true); const unset = () => commit('setIsPublishRequested', false); dispatch('enqueue', () => cb().then(unset, (err) => { unset(); throw err; })); } }, async doWithLocation({ commit }, { location, action }) { try { commit('setCurrentLocation', location); return await action(); } finally { commit('setCurrentLocation', {}); } }, }, }; ================================================ FILE: src/store/syncedContent.js ================================================ import moduleTemplate from './moduleTemplate'; import empty from '../data/empties/emptySyncedContent'; const module = moduleTemplate(empty, true); module.getters = { ...module.getters, current: ({ itemsById }, getters, rootState, rootGetters) => itemsById[`${rootGetters['file/current'].id}/syncedContent`] || empty(), }; export default module; ================================================ FILE: src/store/userInfo.js ================================================ import Vue from 'vue'; export default { namespaced: true, state: { itemsById: {}, }, mutations: { setItem: ({ itemsById }, item) => { const itemToSet = { ...item, }; const existingItem = itemsById[item.id]; if (existingItem) { if (!itemToSet.name) { itemToSet.name = existingItem.name; } if (!itemToSet.imageUrl) { itemToSet.imageUrl = existingItem.imageUrl; } } Vue.set(itemsById, item.id, itemToSet); }, }, }; ================================================ FILE: src/store/workspace.js ================================================ import utils from '../services/utils'; import providerRegistry from '../services/providers/common/providerRegistry'; export default { namespaced: true, state: { currentWorkspaceId: null, lastFocus: 0, }, mutations: { setCurrentWorkspaceId: (state, value) => { state.currentWorkspaceId = value; }, setLastFocus: (state, value) => { state.lastFocus = value; }, }, getters: { workspacesById: (state, getters, rootState, rootGetters) => { const workspacesById = {}; const mainWorkspaceToken = rootGetters['workspace/mainWorkspaceToken']; Object.entries(rootGetters['data/workspaces']).forEach(([id, workspace]) => { const sanitizedWorkspace = { id, providerId: 'googleDriveAppData', sub: mainWorkspaceToken && mainWorkspaceToken.sub, ...workspace, }; // Filter workspaces that don't have a provider const workspaceProvider = providerRegistry.providersById[sanitizedWorkspace.providerId]; if (workspaceProvider) { // Build the url with the current hostname const params = workspaceProvider.getWorkspaceParams(sanitizedWorkspace); sanitizedWorkspace.url = utils.addQueryParams('app', params, true); sanitizedWorkspace.locationUrl = workspaceProvider .getWorkspaceLocationUrl(sanitizedWorkspace); workspacesById[id] = sanitizedWorkspace; } }); return workspacesById; }, mainWorkspace: (state, { workspacesById }) => workspacesById.main, currentWorkspace: ({ currentWorkspaceId }, { workspacesById, mainWorkspace }) => workspacesById[currentWorkspaceId] || mainWorkspace, currentWorkspaceIsGit: (state, { currentWorkspace }) => currentWorkspace.providerId === 'githubWorkspace' || currentWorkspace.providerId === 'gitlabWorkspace', currentWorkspaceHasUniquePaths: (state, { currentWorkspace }) => currentWorkspace.providerId === 'githubWorkspace' || currentWorkspace.providerId === 'gitlabWorkspace', lastSyncActivityKey: (state, { currentWorkspace }) => `${currentWorkspace.id}/lastSyncActivity`, lastFocusKey: (state, { currentWorkspace }) => `${currentWorkspace.id}/lastWindowFocus`, mainWorkspaceToken: (state, getters, rootState, rootGetters) => utils.someResult(Object.values(rootGetters['data/googleTokensBySub']), (token) => { if (token.isLogin) { return token; } return null; }), syncToken: (state, { currentWorkspace, mainWorkspaceToken }, rootState, rootGetters) => { switch (currentWorkspace.providerId) { case 'googleDriveWorkspace': return rootGetters['data/googleTokensBySub'][currentWorkspace.sub]; case 'githubWorkspace': return rootGetters['data/githubTokensBySub'][currentWorkspace.sub]; case 'gitlabWorkspace': return rootGetters['data/gitlabTokensBySub'][currentWorkspace.sub]; case 'couchdbWorkspace': return rootGetters['data/couchdbTokensBySub'][currentWorkspace.id]; default: return mainWorkspaceToken; } }, loginType: (state, { currentWorkspace }) => { switch (currentWorkspace.providerId) { case 'googleDriveWorkspace': default: return 'google'; case 'githubWorkspace': return 'github'; case 'gitlabWorkspace': return 'gitlab'; } }, loginToken: (state, { loginType, currentWorkspace }, rootState, rootGetters) => { const tokensBySub = rootGetters['data/tokensByType'][loginType]; return tokensBySub && tokensBySub[currentWorkspace.sub]; }, sponsorToken: (state, { mainWorkspaceToken }) => mainWorkspaceToken, }, actions: { removeWorkspace: ({ commit, rootGetters }, id) => { const workspaces = { ...rootGetters['data/workspaces'], }; delete workspaces[id]; commit( 'data/setItem', { id: 'workspaces', data: workspaces }, { root: true }, ); }, patchWorkspacesById: ({ commit, rootGetters }, workspaces) => { const sanitizedWorkspaces = {}; Object .entries({ ...rootGetters['data/workspaces'], ...workspaces, }) .forEach(([id, workspace]) => { sanitizedWorkspaces[id] = { ...workspace, id, // Do not store urls url: undefined, locationUrl: undefined, }; }); commit( 'data/setItem', { id: 'workspaces', data: sanitizedWorkspaces }, { root: true }, ); }, setCurrentWorkspaceId: ({ commit, getters }, value) => { commit('setCurrentWorkspaceId', value); const lastFocus = parseInt(localStorage.getItem(getters.lastFocusKey), 10) || 0; commit('setLastFocus', lastFocus); }, }, }; ================================================ FILE: src/styles/app.scss ================================================ @import './variables.scss'; body { background-color: #fff; top: 0; right: 0; bottom: 0; left: 0; position: fixed; tab-size: 4; text-rendering: auto; /* Prevent body overscroll on Chrome */ overflow: hidden; -webkit-overflow-scrolling: touch; } * { box-sizing: border-box; } ::-webkit-scrollbar-track { background-color: transparent; } ::-webkit-scrollbar { background-color: transparent; /* stylelint-disable-next-line selector-pseudo-class-no-unknown */ &:horizontal { height: 8px; } /* stylelint-disable-next-line selector-pseudo-class-no-unknown */ &:vertical { width: 8px; } } ::-webkit-scrollbar-thumb { border-radius: 4px; background-color: #bbb; .app--dark & { background-color: #666; } } :focus { outline: none; } input[type=checkbox] { outline: #349be8 auto 5px; } .icon { width: 100%; height: 100%; display: block; * { fill: currentColor; } } .table-wrapper { max-width: 100%; overflow: auto; } button, input, select, textarea { font-family: inherit; font-size: inherit; line-height: inherit; } .text-input { display: block; font-variant-ligatures: no-common-ligatures; width: 100%; height: 36px; padding: 3px 12px; font-size: inherit; line-height: 1.5; color: inherit; background-color: #fff; background-image: none; border: 0; border-radius: $border-radius-base; } .button { color: #333; background-color: transparent; display: inline-block; height: auto; padding: 8px 16px; font-size: 17px; font-weight: 400; line-height: 1.4; text-transform: uppercase; overflow: hidden; text-align: center; white-space: nowrap; vertical-align: middle; -ms-touch-action: manipulation; touch-action: manipulation; cursor: pointer; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; background-image: none; border: 0; border-radius: $border-radius-base; text-decoration: none; &:active, &:focus, &:hover, .hidden-file:focus + & { color: #333; background-color: rgba(0, 0, 0, 0.05); outline: 0; text-decoration: none; } .app--dark .layout__panel--editor &, .app--dark .layout__panel--preview & { color: #ccc; &:active, &:focus, &:hover { color: #ccc; background-color: rgba(255, 255, 255, 0.067); } } &[disabled] { &, &:active, &:focus, &:hover { opacity: 0.33; background-color: transparent; cursor: not-allowed; } } } .button--resolve { background-color: #349be8; color: #fff; margin: -2px 0 -2px 4px; padding: 10px 20px; font-size: 18px; &:active, &:focus, &:hover { color: #fff; background-color: darken(#349be8, 8%); } } .textfield { background-color: #fff; border: 0; font-family: inherit; font-weight: 400; font-size: 1.05em; padding: 0 0.6rem; box-sizing: border-box; width: 100%; max-width: 100%; color: inherit; height: 2.4rem; &:focus { outline: none; } &[disabled] { cursor: not-allowed; background-color: #f0f0f0; color: #999; } } .flex { display: flex; } .flex--row { flex-direction: row; } .flex--column { flex-direction: column; } .flex--center { justify-content: center; } .flex--end { justify-content: flex-end; } .flex--space-between { justify-content: space-between; } .flex--align-center { align-items: center; } .flex--align-end { align-items: flex-end; } .user-name { font-weight: 600; } .side-title { height: 44px; line-height: 36px; padding: 4px 4px 0; background-color: rgba(0, 0, 0, 0.1); flex: none; } .side-title__button { width: 38px; height: 36px; padding: 6px; display: inline-block; background-color: transparent; opacity: 0.75; flex: none; /* prevent from seeing wrapped buttons */ margin-bottom: 20px; &:active, &:focus, &:hover { opacity: 1; background-color: rgba(0, 0, 0, 0.1); } } .side-title__title { text-transform: uppercase; padding: 0 5px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; width: 100%; } .logo-background { background: no-repeat center url('../assets/logo.svg'); background-size: contain; } .gutter { position: absolute; top: 0; height: 100%; } .gutter__background { position: absolute; height: 100%; right: 0; } .new-discussion-button { color: rgba(0, 0, 0, 0.33); position: absolute; left: 0; padding: 3px 3px 3px 0; width: 22px; height: 21px; line-height: 1; .app--dark & { color: rgba(255, 255, 255, 0.33); } &:active, &:focus, &:hover { color: rgba(0, 0, 0, 0.4); .app--dark & { color: rgba(255, 255, 255, 0.4); } } } .discussion-editor-highlighting, .discussion-preview-highlighting { background-color: mix($editor-background-light, $selection-highlighting-color, 70%); padding: 0.25em 0; .app--dark & { background-color: mix($editor-background-dark, $selection-highlighting-color, 70%); } } .discussion-editor-highlighting--hover, .discussion-preview-highlighting--hover { background-color: mix($editor-background-light, $selection-highlighting-color, 50%); .app--dark & { background-color: mix($editor-background-dark, $selection-highlighting-color, 50%); } * { background-color: transparent; } } .discussion-editor-highlighting--selected, .discussion-preview-highlighting--selected { background-color: mix($editor-background-light, $selection-highlighting-color, 20%); .app--dark & { background-color: mix($editor-background-dark, $selection-highlighting-color, 20%); } * { background-color: transparent; } } .discussion-preview-highlighting { cursor: pointer; &.discussion-preview-highlighting--selected { cursor: auto; } } .hidden-rendering-container { position: absolute; width: 500px; left: -1000px; } @media print { body { background-color: transparent !important; color: #000 !important; // Black prints faster overflow: visible !important; position: absolute !important; div { display: none !important; } a { text-decoration: underline; } } body > .app, body > .app > .layout, body > .app > .layout > .layout__panel, body > .app > .layout > .layout__panel > .layout__panel, body > .app > .layout > .layout__panel > .layout__panel > .layout__panel, body > .app > .layout > .layout__panel > .layout__panel > .layout__panel > .layout__panel--preview, body > .app > .layout > .layout__panel > .layout__panel > .layout__panel > .layout__panel--preview div { background-color: transparent !important; display: block !important; height: auto !important; overflow: visible !important; position: static !important; width: auto !important; font-size: 16px; } .preview__inner-2 { padding: 0 50px !important; } // scss-lint:enable ImportantRule } ================================================ FILE: src/styles/base.scss ================================================ @import '../../node_modules/normalize-scss/sass/normalize'; @import './variables'; @include normalize(); html, body { color: $body-color-light; font-size: 16px; font-family: $font-family-main; font-variant-ligatures: common-ligatures; line-height: $line-height-base; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .app--dark .layout__panel--editor, .app--dark .layout__panel--preview { color: $body-color-dark; } p, blockquote, pre, ul, ol, dl { margin: 1.2em 0; } h1, h2, h3, h4, h5, h6 { margin: 1.8em 0; line-height: $line-height-title; } h1, h2 { &::after { content: ''; display: block; position: relative; top: 0.33em; border-bottom: 1px solid $hr-color; } } ol ul, ul ol, ul ul, ol ol { margin: 0; } dt { font-weight: bold; } a { color: $link-color; text-decoration: underline; text-decoration-skip: ink; &:hover, &:focus { text-decoration: none; } } code, pre, samp { font-family: $font-family-monospace; font-size: $font-size-monospace; * { font-size: inherit; } } blockquote { color: rgba(0, 0, 0, 0.5); padding-left: 1.5em; border-left: 5px solid rgba(0, 0, 0, 0.1); .app--dark .layout__panel--editor &, .app--dark .layout__panel--preview & { color: rgba(255, 255, 255, 0.4); border-left-color: rgba(255, 255, 255, 0.1); } } code { background-color: $code-bg; border-radius: $border-radius-base; padding: 2px 4px; } hr { border: 0; border-top: 1px solid $hr-color; margin: 2em 0; } pre > code { background-color: $code-bg; display: block; padding: 0.5em; -webkit-text-size-adjust: none; overflow-x: auto; white-space: pre; } .toc ul { list-style-type: none; padding-left: 20px; } table { background-color: transparent; border-collapse: collapse; border-spacing: 0; } td, th { border-right: 1px solid #dcdcdc; padding: 8px 12px; &:last-child { border-right: 0; } } td { border-top: 1px solid #dcdcdc; } mark { background-color: #f8f840; } kbd { font-family: $font-family-main; background-color: #fff; border: 1px solid rgba(63, 63, 63, 0.25); border-radius: 3px; box-shadow: 0 1px 0 rgba(63, 63, 63, 0.25); color: #333; display: inline-block; font-size: 0.8em; margin: 0 0.1em; padding: 0.1em 0.6em; white-space: nowrap; } abbr { &[title] { border-bottom: 1px dotted #777; cursor: help; } } img { max-width: 100%; } .task-list-item { list-style-type: none; } .task-list-item-checkbox { margin: 0 0.2em 0 -1.3em; } .footnote { font-size: 0.8em; position: relative; top: -0.25em; vertical-align: top; } .page-break-after { page-break-after: always; } .abc-notation-block { overflow-x: auto !important; } .stackedit__html { margin-bottom: 180px; margin-left: auto; margin-right: auto; padding-left: 30px; padding-right: 30px; max-width: 750px; } .stackedit__toc { ul { padding: 0; a { margin: 0.5rem 0; padding: 0.5rem 1rem; } ul { color: #888; font-size: 0.9em; a { margin: 0; padding: 0.1rem 1rem; } } } li { display: block; } a { display: block; color: inherit; text-decoration: none; &:active, &:focus, &:hover { background-color: rgba(0, 0, 0, 0.075); border-radius: $border-radius-base; } } } .stackedit__left { position: fixed; display: none; width: 250px; height: 100%; top: 0; left: 0; overflow-x: hidden; overflow-y: auto; -webkit-overflow-scrolling: touch; -ms-overflow-style: none; @media (min-width: 1060px) { display: block; } } .stackedit__right { position: absolute; right: 0; top: 0; left: 0; @media (min-width: 1060px) { left: 250px; } } .stackedit--pdf { blockquote { // wkhtmltopdf doesn't like borders with transparency border-left-color: #ececec; } // Hide tex annotations in PDF exports annotation, .katex-mathml { display: none; } .stackedit__html { padding-left: 0; padding-right: 0; max-width: none; } } ================================================ FILE: src/styles/fonts.scss ================================================ @font-face { font-family: 'Lato'; font-style: normal; font-weight: 400; src: url('../assets/fonts/lato-normal.woff') format('woff'); } @font-face { font-family: 'Lato'; font-style: italic; font-weight: 400; src: url('../assets/fonts/lato-normal-italic.woff') format('woff'); } @font-face { font-family: 'Lato'; font-style: normal; font-weight: 600; src: url('../assets/fonts/lato-black.woff') format('woff'); } @font-face { font-family: 'Lato'; font-style: italic; font-weight: 600; src: url('../assets/fonts/lato-black-italic.woff') format('woff'); } @font-face { font-family: 'Roboto Mono'; font-style: normal; font-weight: 400; src: url('../assets/fonts/RobotoMono-Regular.woff') format('woff'); } @font-face { font-family: 'Roboto Mono'; font-style: normal; font-weight: 600; src: url('../assets/fonts/RobotoMono-Bold.woff') format('woff'); } ================================================ FILE: src/styles/index.js ================================================ import 'katex/dist/katex.css'; import './fonts.scss'; import './prism.scss'; import './base.scss'; ================================================ FILE: src/styles/markdownHighlighting.scss ================================================ @import './variables'; .markdown-highlighting { color: $editor-color-light; caret-color: $editor-color-light-low; .app--dark & { color: $editor-color-dark; caret-color: $editor-color-dark-low; } font-family: inherit; font-size: inherit; -webkit-font-smoothing: auto; -moz-osx-font-smoothing: auto; font-weight: $editor-font-weight-base; .code { font-family: $font-family-monospace; font-size: $font-size-monospace; * { font-size: inherit !important; } } .pre { color: $editor-color-light; .app--dark & { color: $editor-color-dark; } font-family: $font-family-monospace; font-size: $font-size-monospace; [class*='language-'] { color: $editor-color-light-low; .app--dark & { color: $editor-color-dark-low; } } * { font-size: inherit !important; } &, * { line-height: $line-height-title; } } .tag { color: $editor-color-light; .app--dark & { color: $editor-color-dark; } font-family: $font-family-monospace; font-size: $font-size-monospace; font-weight: $editor-font-weight-bold; .punctuation, .attr-value, .attr-name { font-weight: $editor-font-weight-base; } * { font-size: inherit !important; } } .latex, .math { color: $editor-color-light; .app--dark & { color: $editor-color-dark; } } .entity { color: $editor-color-light; .app--dark & { color: $editor-color-dark; } font-family: $font-family-monospace; font-size: $font-size-monospace; font-style: italic; * { font-size: inherit !important; } } .table { font-family: $font-family-monospace; font-size: $font-size-monospace; * { font-size: inherit !important; } } .comment { color: $editor-color-light-high; .app--dark & { color: $editor-color-dark-high; } } .keyword { color: $editor-color-light-low; .app--dark & { color: $editor-color-dark-low; } font-weight: $editor-font-weight-bold; } .code, .img, .img-wrapper, .imgref, .cl-toc { background-color: $code-bg; border-radius: $code-border-radius; padding: 0.15em 0; } .img-wrapper { display: inline-block; .img { display: inline-block; padding: 0; background-color: transparent; } img { max-width: 100%; padding: 0 0.15em; box-sizing: content-box; } } .cl-toc { font-size: 2.8em; padding: 0.15em; } .blockquote { color: $editor-color-light-blockquote; .app--dark & { color: $editor-color-dark-blockquote; } } .h1, .h11, .h2, .h22, .h3, .h4, .h5, .h6 { font-weight: $editor-font-weight-bold; &, * { line-height: $line-height-title; } } .h1, .h11 { font-size: 2em; } .h2, .h22 { font-size: 1.5em; } .h3 { font-size: 1.17em; } .h4 { font-size: 1em; } .h5 { font-size: 0.83em; } .h6 { font-size: 0.75em; } .cl-hash { color: $editor-color-light-high; .app--dark & { color: $editor-color-dark-high; } } .cl, .hr { color: $editor-color-light-high; .app--dark & { color: $editor-color-dark-high; } font-style: normal; font-weight: $editor-font-weight-base; } .em, .em .cl { font-style: italic; } .strong, .strong .cl, .term { font-weight: $editor-font-weight-bold; } .cl-del-text { text-decoration: line-through; } .cl-mark-text { background-color: #f8f840; color: $editor-color-light-low; } .url, .email, .cl-underlined-text { text-decoration: underline; } .linkdef .url { color: $editor-color-light-high; .app--dark & { color: $editor-color-dark-high; } } .fn, .inlinefn, .sup { font-size: smaller; position: relative; top: -0.5em; } .sub { bottom: -0.25em; font-size: smaller; position: relative; } .img, .imgref, .link, .linkref { color: $editor-color-light-high; .app--dark & { color: $editor-color-dark-high; } .cl-underlined-text { color: $editor-color-light-low; .app--dark & { color: $editor-color-dark-low; } } } .cl-title { color: $editor-color-light; .app--dark & { color: $editor-color-dark; } } } .markdown-highlighting--inline { .h1, .h11, .h2, .h22, .h3, .h4, .h5, .h6, .cl-toc { font-size: inherit; } } ================================================ FILE: src/styles/prism.scss ================================================ .token.pre.gfm, .prism { * { font-weight: inherit !important; } .token.comment, .token.prolog, .token.doctype, .token.cdata { color: #708090; } .token.punctuation { color: #999; } .namespace { opacity: 0.7; } .token.property, .token.tag, .token.boolean, .token.number, .token.constant, .token.symbol, .token.deleted { color: #905; } .token.selector, .token.attr-name, .token.string, .token.char, .token.builtin, .token.inserted { color: #690; } .token.operator, .token.entity, .token.url, .language-css .token.string, .style .token.string { color: #a67f59; } .token.atrule, .token.attr-value, .token.keyword { color: #07a; } .token.function { color: #dd4a68; } .token.regex, .token.important, .token.variable { color: #e90; } .token.important, .token.bold { font-weight: 500; } .token.italic { font-style: italic; } } ================================================ FILE: src/styles/variables.scss ================================================ $font-family-main: Lato, 'Helvetica Neue', Helvetica, sans-serif; $font-family-monospace: 'Roboto Mono', 'Lucida Sans Typewriter', 'Lucida Console', monaco, Courrier, monospace; $body-color-light: rgba(0, 0, 0, 0.75); $body-color-dark: rgba(255, 255, 255, 0.75); $code-bg: rgba(0, 0, 0, 0.05); $line-height-base: 1.67; $line-height-title: 1.33; $font-size-monospace: 0.85em; $highlighting-color: #ff0; $selection-highlighting-color: #ff9632; $info-bg: #ffad3326; $code-border-radius: 3px; $link-color: #0c93e4; $error-color: #f31; $border-radius-base: 3px; $hr-color: rgba(128, 128, 128, 0.33); $navbar-bg: #2c2c2c; $navbar-color: mix($navbar-bg, #fff, 33%); $navbar-hover-color: #fff; $navbar-hover-background: rgba(255, 255, 255, 0.1); $editor-background-light: #fff; $editor-background-dark: #1e1e1e; $editor-color-light: rgba(0, 0, 0, 0.8); $editor-color-light-low: #000; $editor-color-light-high: rgba(0, 0, 0, 0.28); $editor-color-light-blockquote: rgba(0, 0, 0, 0.48); $editor-color-dark: rgba(255, 255, 255, 0.8); $editor-color-dark-low: #fff; $editor-color-dark-high: rgba(255, 255, 255, 0.28); $editor-color-dark-blockquote: rgba(255, 255, 255, 0.48); $editor-font-weight-base: 400; $editor-font-weight-bold: 600; ================================================ FILE: static/landing/index.html ================================================ StackEdit – In-browser Markdown editor

      Unrivalled writing experience

      Rich Markdown editor

      StackEdit’s Markdown syntax highlighting is unique. The refined text formatting of the editor helps you visualize the final rendering of your files.

      WYSIWYG controls

      StackEdit provides very handy formatting buttons and shortcuts, thanks to PageDown, the WYSIWYG-style Markdown editor used by Stack Overflow.

      Smart layout

      Whether you write, you review, you comment… StackEdit's layout provides you with the flexibility you need, without sacrifice.

      Live preview with Scroll Sync

      StackEdit’s Scroll Sync feature accurately binds the scrollbars of the editor panel and the preview panel to ensure that you always keep an eye on the output while writing.

      Designed for web writers

      Stay connected

      StackEdit can sync your files with Google Drive, Dropbox and GitHub. It can also publish them as blog posts to Blogger, WordPress and Zendesk. You can choose whether to upload in Markdown format, HTML, or to format the output using the Handlebars template engine.

      Collaborate

      With StackEdit, you can share collaborative workspaces, thanks to the synchronization mechanism. If two collaborators are working on the same file at the same time, StackEdit takes care of merging the changes.

      Comment

      StackEdit allows you to insert inline comments and embed collaborator discussions in your files, just as well as Microsoft Word and Google Docs.

      Write offline!

      Even when you travel, StackEdit is still accessible and lets you write offline just like any desktop application. You have no excuse!

      Extended Markdown support


      GitHub Flavored Markdown

      StackEdit supports different Markdown flavors such as Markdown Extra, GFM and CommonMark. Each Markdown feature can be enabled or disabled at your convenience.


      LaTeX mathematical expressions

      StackEdit renders mathematics from LaTeX expressions inside your markdown file, as you would do on Stack Exchange.

      UML diagrams

      StackEdit enables you to write sequence diagrams and flow charts using a simple syntax.

      Scores

      StackEdit can render musical scores using the ABC notation.

      Emojis

      StackEdit supports inserting emojis in your file using the Markdown emoji markup.

      ================================================ FILE: static/oauth2/callback.html ================================================ ================================================ FILE: static/sitemap.xml ================================================ https://stackedit.io/ weekly 1.0 https://stackedit.io/app weekly 1.0 https://community.stackedit.io/ weekly 0.8 https://stackedit.io/privacy_policy.html monthly 0.6 ================================================ FILE: test/unit/.eslintrc ================================================ { "env": { "jest": true }, "extends": [ "../../.eslintrc.js" ] } ================================================ FILE: test/unit/jest.conf.js ================================================ const path = require('path'); module.exports = { rootDir: path.resolve(__dirname, '../../'), moduleFileExtensions: [ 'js', 'json', 'vue', ], moduleNameMapper: { '\\.(css|scss)$': 'identity-obj-proxy', '^!raw-loader!': 'identity-obj-proxy', '^worker-loader!\\./templateWorker\\.js$': '/test/unit/mocks/templateWorkerMock', }, transform: { '^.+\\.js$': '/node_modules/babel-jest', '.*\\.(vue)$': '/node_modules/vue-jest', '.*\\.(yml|html|md)$': 'jest-raw-loader', }, snapshotSerializers: ['/node_modules/jest-serializer-vue'], setupFiles: [ '/test/unit/setup', ], coverageDirectory: '/test/unit/coverage', collectCoverageFrom: [ 'src/**/*.{js,vue}', '!src/main.js', '!**/node_modules/**', ], globals: { NODE_ENV: 'production', }, }; ================================================ FILE: test/unit/mocks/cryptoMock.js ================================================ window.crypto = { getRandomValues(array) { for (let i = 0; i < array.length; i += 1) { array[i] = Math.floor(Math.random() * 1000000); } }, }; ================================================ FILE: test/unit/mocks/localStorageMock.js ================================================ const store = {}; window.localStorage = { getItem(key) { return store[key] || null; }, setItem(key, value) { store[key] = value.toString(); }, }; ================================================ FILE: test/unit/mocks/mutationObserverMock.js ================================================ /* eslint-disable class-methods-use-this */ class MutationObserver { observe() { } } window.MutationObserver = MutationObserver; ================================================ FILE: test/unit/mocks/templateWorkerMock.js ================================================ module.exports = 'test-file-stub'; ================================================ FILE: test/unit/setup.js ================================================ import Vue from 'vue'; import './mocks/cryptoMock'; import './mocks/mutationObserverMock'; Vue.config.productionTip = false; ================================================ FILE: test/unit/specs/components/ButtonBar.spec.js ================================================ import ButtonBar from '../../../../src/components/ButtonBar'; import store from '../../../../src/store'; import specUtils from '../specUtils'; describe('ButtonBar.vue', () => { it('should toggle the navigation bar', async () => specUtils.checkToggler( ButtonBar, wrapper => wrapper.find('.button-bar__button--navigation-bar-toggler').trigger('click'), () => store.getters['data/layoutSettings'].showNavigationBar, 'toggleNavigationBar', )); it('should toggle the side preview', async () => specUtils.checkToggler( ButtonBar, wrapper => wrapper.find('.button-bar__button--side-preview-toggler').trigger('click'), () => store.getters['data/layoutSettings'].showSidePreview, 'toggleSidePreview', )); it('should toggle the editor', async () => specUtils.checkToggler( ButtonBar, wrapper => wrapper.find('.button-bar__button--editor-toggler').trigger('click'), () => store.getters['data/layoutSettings'].showEditor, 'toggleEditor', )); it('should toggle the focus mode', async () => specUtils.checkToggler( ButtonBar, wrapper => wrapper.find('.button-bar__button--focus-mode-toggler').trigger('click'), () => store.getters['data/layoutSettings'].focusMode, 'toggleFocusMode', )); it('should toggle the scroll sync', async () => specUtils.checkToggler( ButtonBar, wrapper => wrapper.find('.button-bar__button--scroll-sync-toggler').trigger('click'), () => store.getters['data/layoutSettings'].scrollSync, 'toggleScrollSync', )); it('should toggle the status bar', async () => specUtils.checkToggler( ButtonBar, wrapper => wrapper.find('.button-bar__button--status-bar-toggler').trigger('click'), () => store.getters['data/layoutSettings'].showStatusBar, 'toggleStatusBar', )); }); ================================================ FILE: test/unit/specs/components/ContextMenu.spec.js ================================================ import { shallowMount } from '@vue/test-utils'; import ContextMenu from '../../../../src/components/ContextMenu'; import store from '../../../../src/store'; import '../specUtils'; const mount = () => shallowMount(ContextMenu, { store }); describe('ContextMenu.vue', () => { const name = 'Name'; const makeOptions = () => ({ coordinates: { left: 0, top: 0, }, items: [{ name }], }); it('should open/close itself', async () => { const wrapper = mount(); expect(wrapper.contains('.context-menu__item')).toEqual(false); setTimeout(() => wrapper.find('.context-menu__item').trigger('click'), 1); const item = await store.dispatch('contextMenu/open', makeOptions()); expect(item.name).toEqual(name); }); it('should cancel itself', async () => { const wrapper = mount(); setTimeout(() => wrapper.trigger('click'), 1); const item = await store.dispatch('contextMenu/open', makeOptions()); expect(item).toEqual(null); }); }); ================================================ FILE: test/unit/specs/components/Explorer.spec.js ================================================ import { shallowMount } from '@vue/test-utils'; import Explorer from '../../../../src/components/Explorer'; import store from '../../../../src/store'; import workspaceSvc from '../../../../src/services/workspaceSvc'; import specUtils from '../specUtils'; const mount = () => shallowMount(Explorer, { store }); const select = (id) => { store.commit('explorer/setSelectedId', id); expect(store.getters['explorer/selectedNode'].item.id).toEqual(id); }; const ensureExists = file => expect(store.getters.allItemsById).toHaveProperty(file.id); const ensureNotExists = file => expect(store.getters.allItemsById).not.toHaveProperty(file.id); const refreshItem = item => store.getters.allItemsById[item.id]; describe('Explorer.vue', () => { it('should create new file in the root folder', async () => { expect(store.state.explorer.newChildNode.isNil).toBeTruthy(); const wrapper = mount(); wrapper.find('.side-title__button--new-file').trigger('click'); expect(store.state.explorer.newChildNode.isNil).toBeFalsy(); expect(store.state.explorer.newChildNode.item).toMatchObject({ type: 'file', parentId: null, }); }); it('should create new file in a folder', async () => { const folder = await workspaceSvc.storeItem({ type: 'folder' }); const wrapper = mount(); select(folder.id); wrapper.find('.side-title__button--new-file').trigger('click'); expect(store.state.explorer.newChildNode.item).toMatchObject({ type: 'file', parentId: folder.id, }); }); it('should not create new files in the trash folder', async () => { const wrapper = mount(); select('trash'); wrapper.find('.side-title__button--new-file').trigger('click'); expect(store.state.explorer.newChildNode.item).toMatchObject({ type: 'file', parentId: null, }); }); it('should create new folders in the root folder', async () => { expect(store.state.explorer.newChildNode.isNil).toBeTruthy(); const wrapper = mount(); wrapper.find('.side-title__button--new-folder').trigger('click'); expect(store.state.explorer.newChildNode.isNil).toBeFalsy(); expect(store.state.explorer.newChildNode.item).toMatchObject({ type: 'folder', parentId: null, }); }); it('should create new folders in a folder', async () => { const folder = await workspaceSvc.storeItem({ type: 'folder' }); const wrapper = mount(); select(folder.id); wrapper.find('.side-title__button--new-folder').trigger('click'); expect(store.state.explorer.newChildNode.item).toMatchObject({ type: 'folder', parentId: folder.id, }); }); it('should not create new folders in the trash folder', async () => { const wrapper = mount(); select('trash'); wrapper.find('.side-title__button--new-folder').trigger('click'); expect(store.state.explorer.newChildNode.item).toMatchObject({ type: 'folder', parentId: null, }); }); it('should not create new folders in the temp folder', async () => { const wrapper = mount(); select('temp'); wrapper.find('.side-title__button--new-folder').trigger('click'); expect(store.state.explorer.newChildNode.item).toMatchObject({ type: 'folder', parentId: null, }); }); it('should move file to the trash folder on delete', async () => { const file = await workspaceSvc.createFile({}, true); expect(file.parentId).toEqual(null); const wrapper = mount(); select(file.id); wrapper.find('.side-title__button--delete').trigger('click'); ensureExists(file); expect(refreshItem(file).parentId).toEqual('trash'); await specUtils.expectBadge('removeFile'); }); it('should not delete the trash folder', async () => { const wrapper = mount(); select('trash'); wrapper.find('.side-title__button--delete').trigger('click'); await specUtils.resolveModal('trashDeletion'); await specUtils.expectBadge('removeFile', false); }); it('should not delete file in the trash folder', async () => { const file = await workspaceSvc.createFile({ parentId: 'trash' }, true); const wrapper = mount(); select(file.id); wrapper.find('.side-title__button--delete').trigger('click'); await specUtils.resolveModal('trashDeletion'); ensureExists(file); await specUtils.expectBadge('removeFile', false); }); it('should delete the temp folder after confirmation', async () => { const file = await workspaceSvc.createFile({ parentId: 'temp' }, true); const wrapper = mount(); select('temp'); wrapper.find('.side-title__button--delete').trigger('click'); await specUtils.resolveModal('tempFolderDeletion'); ensureNotExists(file); await specUtils.expectBadge('removeFolder'); }); it('should delete temp file after confirmation', async () => { const file = await workspaceSvc.createFile({ parentId: 'temp' }, true); const wrapper = mount(); select(file.id); wrapper.find('.side-title__button--delete').trigger('click'); ensureExists(file); await specUtils.resolveModal('tempFileDeletion'); ensureNotExists(file); await specUtils.expectBadge('removeFile'); }); it('should delete folder after confirmation', async () => { const folder = await workspaceSvc.storeItem({ type: 'folder' }); const file = await workspaceSvc.createFile({ parentId: folder.id }, true); const wrapper = mount(); select(folder.id); wrapper.find('.side-title__button--delete').trigger('click'); await specUtils.resolveModal('folderDeletion'); ensureNotExists(folder); // Make sure file has been moved to Trash ensureExists(file); expect(refreshItem(file).parentId).toEqual('trash'); await specUtils.expectBadge('removeFolder'); }); it('should rename file', async () => { const file = await workspaceSvc.createFile({}, true); const wrapper = mount(); select(file.id); wrapper.find('.side-title__button--rename').trigger('click'); expect(store.getters['explorer/editingNode'].item.id).toEqual(file.id); }); it('should rename folder', async () => { const folder = await workspaceSvc.storeItem({ type: 'folder' }); const wrapper = mount(); select(folder.id); wrapper.find('.side-title__button--rename').trigger('click'); expect(store.getters['explorer/editingNode'].item.id).toEqual(folder.id); }); it('should not rename the trash folder', async () => { const wrapper = mount(); select('trash'); wrapper.find('.side-title__button--rename').trigger('click'); expect(store.getters['explorer/editingNode'].isNil).toBeTruthy(); }); it('should not rename the temp folder', async () => { const wrapper = mount(); select('temp'); wrapper.find('.side-title__button--rename').trigger('click'); expect(store.getters['explorer/editingNode'].isNil).toBeTruthy(); }); it('should close itself', async () => { store.dispatch('data/toggleExplorer', true); specUtils.checkToggler( Explorer, wrapper => wrapper.find('.side-title__button--close').trigger('click'), () => store.getters['data/layoutSettings'].showExplorer, 'toggleExplorer', ); }); }); ================================================ FILE: test/unit/specs/components/ExplorerNode.spec.js ================================================ import { shallowMount } from '@vue/test-utils'; import ExplorerNode from '../../../../src/components/ExplorerNode'; import store from '../../../../src/store'; import workspaceSvc from '../../../../src/services/workspaceSvc'; import explorerSvc from '../../../../src/services/explorerSvc'; import specUtils from '../specUtils'; const makeFileNode = async () => { const file = await workspaceSvc.createFile({}, true); const node = store.getters['explorer/nodeMap'][file.id]; expect(node.item.id).toEqual(file.id); return node; }; const makeFolderNode = async () => { const folder = await workspaceSvc.storeItem({ type: 'folder' }); const node = store.getters['explorer/nodeMap'][folder.id]; expect(node.item.id).toEqual(folder.id); return node; }; const mount = node => shallowMount(ExplorerNode, { store, propsData: { node, depth: 1 }, }); const mountAndSelect = (node) => { const wrapper = mount(node); wrapper.find('.explorer-node__item').trigger('click'); expect(store.getters['explorer/selectedNode'].item.id).toEqual(node.item.id); expect(wrapper.classes()).toContain('explorer-node--selected'); return wrapper; }; const dragAndDrop = (sourceItem, targetItem) => { const sourceNode = store.getters['explorer/nodeMap'][sourceItem.id]; mountAndSelect(sourceNode).find('.explorer-node__item').trigger('dragstart', { dataTransfer: { setData: () => {} }, }); expect(store.state.explorer.dragSourceId).toEqual(sourceItem.id); const targetNode = store.getters['explorer/nodeMap'][targetItem.id]; const wrapper = mount(targetNode); wrapper.trigger('dragenter'); expect(store.state.explorer.dragTargetId).toEqual(targetItem.id); wrapper.trigger('drop'); const expectedParentId = targetItem.type === 'file' ? targetItem.parentId : targetItem.id; expect(store.getters['explorer/selectedNode'].item.parentId).toEqual(expectedParentId); }; describe('ExplorerNode.vue', () => { const modifiedName = 'Name'; it('should open file on select after a timeout', async () => { const node = await makeFileNode(); mountAndSelect(node); expect(store.getters['file/current'].id).not.toEqual(node.item.id); await new Promise(resolve => setTimeout(resolve, 10)); expect(store.getters['file/current'].id).toEqual(node.item.id); await specUtils.expectBadge('switchFile'); }); it('should not open already open file', async () => { const node = await makeFileNode(); store.commit('file/setCurrentId', node.item.id); mountAndSelect(node); await new Promise(resolve => setTimeout(resolve, 10)); expect(store.getters['file/current'].id).toEqual(node.item.id); await specUtils.expectBadge('switchFile', false); }); it('should open folder on select after a timeout', async () => { const node = await makeFolderNode(); const wrapper = mountAndSelect(node); expect(wrapper.classes()).not.toContain('explorer-node--open'); await new Promise(resolve => setTimeout(resolve, 10)); expect(wrapper.classes()).toContain('explorer-node--open'); }); it('should open folder on new child', async () => { const node = await makeFolderNode(); const wrapper = mountAndSelect(node); // Close the folder wrapper.find('.explorer-node__item').trigger('click'); await new Promise(resolve => setTimeout(resolve, 10)); expect(wrapper.classes()).not.toContain('explorer-node--open'); explorerSvc.newItem(); expect(wrapper.classes()).toContain('explorer-node--open'); }); it('should create new file in a folder', async () => { const node = await makeFolderNode(); const wrapper = mount(node); wrapper.trigger('contextmenu'); await specUtils.resolveContextMenu('New file'); expect(wrapper.contains('.explorer-node__new-child')).toBe(true); store.commit('explorer/setNewItemName', modifiedName); wrapper.find('.explorer-node__new-child .text-input').trigger('blur'); await new Promise(resolve => setTimeout(resolve, 1)); expect(store.getters['explorer/selectedNode'].item).toMatchObject({ name: modifiedName, type: 'file', parentId: node.item.id, }); expect(wrapper.contains('.explorer-node__new-child')).toBe(false); await specUtils.expectBadge('createFile'); }); it('should cancel file creation on escape', async () => { const node = await makeFolderNode(); const wrapper = mount(node); wrapper.trigger('contextmenu'); await specUtils.resolveContextMenu('New file'); expect(wrapper.contains('.explorer-node__new-child')).toBe(true); store.commit('explorer/setNewItemName', modifiedName); wrapper.find('.explorer-node__new-child .text-input').trigger('keydown', { keyCode: 27, }); await new Promise(resolve => setTimeout(resolve, 1)); expect(store.getters['explorer/selectedNode'].item).not.toMatchObject({ name: 'modifiedName', type: 'file', parentId: node.item.id, }); expect(wrapper.contains('.explorer-node__new-child')).toBe(false); await specUtils.expectBadge('createFile', false); }); it('should not create new file in a file', async () => { const node = await makeFileNode(); mount(node).trigger('contextmenu'); expect(specUtils.getContextMenuItem('New file').disabled).toBe(true); }); it('should not create new file in the trash folder', async () => { const node = store.getters['explorer/nodeMap'].trash; mount(node).trigger('contextmenu'); expect(specUtils.getContextMenuItem('New file').disabled).toBe(true); }); it('should create new folder in folder', async () => { const node = await makeFolderNode(); const wrapper = mount(node); wrapper.trigger('contextmenu'); await specUtils.resolveContextMenu('New folder'); expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(true); store.commit('explorer/setNewItemName', modifiedName); wrapper.find('.explorer-node__new-child--folder .text-input').trigger('blur'); await new Promise(resolve => setTimeout(resolve, 1)); expect(store.getters['explorer/selectedNode'].item).toMatchObject({ name: modifiedName, type: 'folder', parentId: node.item.id, }); expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(false); await specUtils.expectBadge('createFolder'); }); it('should cancel folder creation on escape', async () => { const node = await makeFolderNode(); const wrapper = mount(node); wrapper.trigger('contextmenu'); await specUtils.resolveContextMenu('New folder'); expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(true); store.commit('explorer/setNewItemName', modifiedName); wrapper.find('.explorer-node__new-child--folder .text-input').trigger('keydown', { keyCode: 27, }); await new Promise(resolve => setTimeout(resolve, 1)); expect(store.getters['explorer/selectedNode'].item).not.toMatchObject({ name: modifiedName, type: 'folder', parentId: node.item.id, }); expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(false); await specUtils.expectBadge('createFolder', false); }); it('should not create new folder in a file', async () => { const node = await makeFileNode(); mount(node).trigger('contextmenu'); expect(specUtils.getContextMenuItem('New folder').disabled).toBe(true); }); it('should not create new folder in the trash folder', async () => { const node = store.getters['explorer/nodeMap'].trash; mount(node).trigger('contextmenu'); expect(specUtils.getContextMenuItem('New folder').disabled).toBe(true); }); it('should not create new folder in the temp folder', async () => { const node = store.getters['explorer/nodeMap'].temp; mount(node).trigger('contextmenu'); expect(specUtils.getContextMenuItem('New folder').disabled).toBe(true); }); it('should rename file', async () => { const node = await makeFileNode(); const wrapper = mount(node); wrapper.trigger('contextmenu'); await specUtils.resolveContextMenu('Rename'); expect(wrapper.contains('.explorer-node__item-editor')).toBe(true); wrapper.setData({ editingValue: modifiedName }); wrapper.find('.explorer-node__item-editor .text-input').trigger('blur'); expect(store.getters['explorer/selectedNode'].item.name).toEqual(modifiedName); await specUtils.expectBadge('renameFile'); }); it('should cancel rename file on escape', async () => { const node = await makeFileNode(); const wrapper = mount(node); wrapper.trigger('contextmenu'); await specUtils.resolveContextMenu('Rename'); expect(wrapper.contains('.explorer-node__item-editor')).toBe(true); wrapper.setData({ editingValue: modifiedName }); wrapper.find('.explorer-node__item-editor .text-input').trigger('keydown', { keyCode: 27, }); expect(store.getters['explorer/selectedNode'].item.name).not.toEqual(modifiedName); await specUtils.expectBadge('renameFile', false); }); it('should rename folder', async () => { const node = await makeFolderNode(); const wrapper = mount(node); wrapper.trigger('contextmenu'); await specUtils.resolveContextMenu('Rename'); expect(wrapper.contains('.explorer-node__item-editor')).toBe(true); wrapper.setData({ editingValue: modifiedName }); wrapper.find('.explorer-node__item-editor .text-input').trigger('blur'); expect(store.getters['explorer/selectedNode'].item.name).toEqual(modifiedName); await specUtils.expectBadge('renameFolder'); }); it('should cancel rename folder on escape', async () => { const node = await makeFolderNode(); const wrapper = mount(node); wrapper.trigger('contextmenu'); await specUtils.resolveContextMenu('Rename'); expect(wrapper.contains('.explorer-node__item-editor')).toBe(true); wrapper.setData({ editingValue: modifiedName }); wrapper.find('.explorer-node__item-editor .text-input').trigger('keydown', { keyCode: 27, }); expect(store.getters['explorer/selectedNode'].item.name).not.toEqual(modifiedName); await specUtils.expectBadge('renameFolder', false); }); it('should not rename the trash folder', async () => { const node = store.getters['explorer/nodeMap'].trash; mount(node).trigger('contextmenu'); expect(specUtils.getContextMenuItem('Rename').disabled).toBe(true); }); it('should not rename the temp folder', async () => { const node = store.getters['explorer/nodeMap'].temp; mount(node).trigger('contextmenu'); expect(specUtils.getContextMenuItem('Rename').disabled).toBe(true); }); it('should move file into a folder', async () => { const sourceItem = await workspaceSvc.createFile({}, true); const targetItem = await workspaceSvc.storeItem({ type: 'folder' }); dragAndDrop(sourceItem, targetItem); await specUtils.expectBadge('moveFile'); }); it('should move folder into a folder', async () => { const sourceItem = await workspaceSvc.storeItem({ type: 'folder' }); const targetItem = await workspaceSvc.storeItem({ type: 'folder' }); dragAndDrop(sourceItem, targetItem); await specUtils.expectBadge('moveFolder'); }); it('should move file into a file parent folder', async () => { const targetItem = await workspaceSvc.storeItem({ type: 'folder' }); const file = await workspaceSvc.createFile({ parentId: targetItem.id }, true); const sourceItem = await workspaceSvc.createFile({}, true); dragAndDrop(sourceItem, file); await specUtils.expectBadge('moveFile'); }); it('should not move the trash folder', async () => { const sourceNode = store.getters['explorer/nodeMap'].trash; mountAndSelect(sourceNode).find('.explorer-node__item').trigger('dragstart'); expect(store.state.explorer.dragSourceId).not.toEqual('trash'); }); it('should not move the temp folder', async () => { const sourceNode = store.getters['explorer/nodeMap'].temp; mountAndSelect(sourceNode).find('.explorer-node__item').trigger('dragstart'); expect(store.state.explorer.dragSourceId).not.toEqual('temp'); }); it('should not move file to the temp folder', async () => { const targetNode = store.getters['explorer/nodeMap'].temp; const wrapper = mount(targetNode); wrapper.trigger('dragenter'); expect(store.state.explorer.dragTargetId).not.toEqual('temp'); }); it('should not move file to a file in the temp folder', async () => { const file = await workspaceSvc.createFile({ parentId: 'temp' }, true); const targetNode = store.getters['explorer/nodeMap'][file.id]; const wrapper = mount(targetNode); wrapper.trigger('dragenter'); expect(store.state.explorer.dragTargetId).not.toEqual(file.id); }); }); ================================================ FILE: test/unit/specs/components/NavigationBar.spec.js ================================================ import NavigationBar from '../../../../src/components/NavigationBar'; import store from '../../../../src/store'; import specUtils from '../specUtils'; describe('NavigationBar.vue', () => { it('should toggle the explorer', async () => specUtils.checkToggler( NavigationBar, wrapper => wrapper.find('.navigation-bar__button--explorer-toggler').trigger('click'), () => store.getters['data/layoutSettings'].showExplorer, 'toggleExplorer', )); it('should toggle the side bar', async () => specUtils.checkToggler( NavigationBar, wrapper => wrapper.find('.navigation-bar__button--stackedit').trigger('click'), () => store.getters['data/layoutSettings'].showSideBar, 'toggleSideBar', )); }); ================================================ FILE: test/unit/specs/components/Notification.spec.js ================================================ import { shallowMount } from '@vue/test-utils'; import Notification from '../../../../src/components/Notification'; import store from '../../../../src/store'; import '../specUtils'; const mount = () => shallowMount(Notification, { store }); describe('Notification.vue', () => { it('should autoclose itself', async () => { const wrapper = mount(); expect(wrapper.contains('.notification__item')).toBe(false); store.dispatch('notification/showItem', { type: 'info', content: 'Test', timeout: 10, }); expect(wrapper.contains('.notification__item')).toBe(true); await new Promise(resolve => setTimeout(resolve, 10)); expect(wrapper.contains('.notification__item')).toBe(false); }); it('should show messages from top to bottom', async () => { const wrapper = mount(); store.dispatch('notification/info', 'Test 1'); store.dispatch('notification/info', 'Test 2'); const items = wrapper.findAll('.notification__item'); expect(items.length).toEqual(2); expect(items.at(0).text()).toMatch(/Test 1/); expect(items.at(1).text()).toMatch(/Test 2/); }); it('should not open the same message twice', async () => { const wrapper = mount(); store.dispatch('notification/info', 'Test'); store.dispatch('notification/info', 'Test'); expect(wrapper.findAll('.notification__item').length).toEqual(1); }); }); ================================================ FILE: test/unit/specs/specUtils.js ================================================ import { shallowMount } from '@vue/test-utils'; import store from '../../../src/store'; import utils from '../../../src/services/utils'; import '../../../src/icons'; import '../../../src/components/common/vueGlobals'; const clone = object => JSON.parse(JSON.stringify(object)); const deepAssign = (target, origin) => { Object.entries(origin).forEach(([key, value]) => { const type = Object.prototype.toString.call(value); if (type === '[object Object]' && Object.keys(value).length) { deepAssign(target[key], value); } else { target[key] = value; } }); }; const freshState = clone(store.state); beforeEach(() => { // Restore store state before each test deepAssign(store.state, clone(freshState)); }); export default { async checkToggler(Component, toggler, checker, featureId) { const wrapper = shallowMount(Component, { store }); const valueBefore = checker(); toggler(wrapper); const valueAfter = checker(); expect(valueAfter).toEqual(!valueBefore); await this.expectBadge(featureId); }, async resolveModal(type) { const config = store.getters['modal/config']; expect(config).toBeTruthy(); expect(config.type).toEqual(type); config.resolve(); await new Promise(resolve => setTimeout(resolve, 1)); }, getContextMenuItem(name) { return utils.someResult(store.state.contextMenu.items, item => item.name === name && item); }, async resolveContextMenu(name) { const item = this.getContextMenuItem(name); expect(item).toBeTruthy(); store.state.contextMenu.resolve(item); await new Promise(resolve => setTimeout(resolve, 1)); }, async expectBadge(featureId, isEarned = true) { await new Promise(resolve => setTimeout(resolve, 1)); expect(store.getters['data/allBadges'].filter(badge => badge.featureId === featureId)[0]).toMatchObject({ isEarned, }); }, };