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 [](https://travis-ci.org/benweet/stackedit) [](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 ================================================
The new StackEdit 5 is here!
Please click Next to take a quick tour.
StackEdit converts your Markdown to HTML in real-time.
Click
StackEdit can manage multiple files and folders in a workspace.
Click
StackEdit can also synchronize and publish your files, manage collaborative workspaces...
Click
If you like StackEdit, please rate 5 stars on the Chrome Web Store.
You can also star the project on GitHub and join the community.
StackEdit has access to the following external accounts:
StackEdit has no access to any external account yet.
{{badgeCount}} badges earned
{{badgeCount}} badge earned
ProTip: You can manually toggle extensions:
extensions:
emoji:
# Enable emoji shortcuts like :) :-(
shortcuts: true
Use preset zero to make your own configuration:
extensions:
preset: zero
markdown:
table: true
katex:
enabled: true
For the full list of options, see here.
Please choose a template for your HTML export.
Please provide a URL for your image.
Please provide a URL for your link.
Please choose a format for your Pandoc export.
Please choose a template for your PDF export.
{{currentFileName}} is published to the following location(s):
{{currentFileName}} is not published yet.
Please choose a PayPal option:
{{currentFileName}} is synchronized with the following location(s):
{{currentFileName}} is not synchronized yet.
The following workspaces are accessible:
Publish {{currentFileName}} to your Blogger Page.
title in the file properties.
Publish {{currentFileName}} to your Blogger site.
Please provide your credentials to login to CouchDB.
Create a workspace synced with a CouchDB database.
Link your Dropbox account to StackEdit.
Publish {{currentFileName}} to your Dropbox.
Save {{currentFileName}} to your Dropbox and keep it synced.
Publish {{currentFileName}} to a Gist.
title in the file properties.
Save {{currentFileName}} to a Gist and keep it synced.
Link your GitHub account to StackEdit.
Open a file from your GitHub repository and keep it synced.
master branch will be used.
Publish {{currentFileName}} to your GitHub repository.
master branch will be used.
Save {{currentFileName}} to your GitHub repository and keep it synced.
master branch will be used.
Create a workspace synced with a GitHub repository folder.
master branch will be used.
Link your GitLab account to StackEdit.
Open a file from your GitLab project and keep it synced.
master branch will be used.
Publish {{currentFileName}} to your GitLab project.
master branch will be used.
Save {{currentFileName}} to your GitLab project and keep it synced.
master branch will be used.
Create a workspace synced with a GitLab project folder.
master branch will be used.
Link your Google Drive account to StackEdit.
Publish {{currentFileName}} to your Google Drive account.
title in the file properties.
Save {{currentFileName}} to your Google Drive account and keep it synced.
Create a workspace synced with a Google Drive folder.
Publish {{currentFileName}} to your WordPress site.
title, tags,
categories, excerpt, author, featuredImage,
status and date in the file properties.
Link your Zendesk account to StackEdit.
Publish {{currentFileName}} to your Zendesk Help Center.
]*>/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: 
A sized image: 
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.
Note: This will sync your main workspace.`,
'Cancel',
'Ok, sign in',
),
signInForSponsorship: simpleModal(
`You have to sign in with Google to sponsor.
Note: This will sync your main workspace.`,
'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 = /^,
BEGING_END_TAGE_REGEXP = /^<\//,
COMMENT_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('');
buf.push(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('');
out(tag);
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]*(\S+?)>?[ \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
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,
});
},
};