├── .github └── ISSUE_TEMPLATE.md ├── .gitignore ├── LICENSE ├── README.jsdoc.md ├── README.md ├── config ├── csslintrc.json ├── eslintrc.mjs └── jsdoc.json ├── doc ├── presentations │ ├── 2014-02-01-FOSDEM │ │ ├── .gitignore │ │ ├── 2014-02-01-fosdem-lightning-talk-sozi.screen.sozi.html │ │ ├── 2014-02-01-fosdem-lightning-talk-sozi.screen.sozi.json │ │ ├── 2014-02-01-fosdem-lightning-talk-sozi.screen.svg │ │ └── 2014-02-01-fosdem-lightning-talk-sozi.svg │ └── Website │ │ ├── ceci-nest-pas-un-diaporama.fast.sozi.html │ │ ├── ceci-nest-pas-un-diaporama.fast.sozi.json │ │ ├── ceci-nest-pas-un-diaporama.fast.svg │ │ ├── ceci-nest-pas-un-diaporama.svg │ │ ├── isso-nao-e-um-slideshow.html │ │ ├── isso-nao-e-um-slideshow.json │ │ ├── isso-nao-e-um-slideshow.svg │ │ ├── this-is-not-a-slideshow.fast.sozi.html │ │ ├── this-is-not-a-slideshow.fast.sozi.json │ │ ├── this-is-not-a-slideshow.fast.svg │ │ └── this-is-not-a-slideshow.svg ├── sozi-logo-bg.png ├── sozi-logo-bg.svg ├── sozi-logo.svg ├── startup.plantuml └── startup.png ├── extras ├── media-inkscape-0.92 │ ├── sozi_extras_media.inx │ └── sozi_extras_media.py ├── media-inkscape-1.0 │ ├── sozi_extras_media.inx │ └── sozi_extras_media.py └── texts2paths │ └── texts2paths.py ├── gulpfile.js ├── jsconfig.json ├── locales ├── ar.po ├── bg.po ├── ca.po ├── cs.po ├── da.po ├── de.po ├── el.po ├── eo.po ├── es.po ├── et.po ├── fa.po ├── fi.po ├── fr.po ├── gl.po ├── he.po ├── hu.po ├── id.po ├── it.po ├── ja.po ├── kab.po ├── ko.po ├── lt.po ├── lzh.po ├── messages.pot ├── ms.po ├── nb.po ├── nl.po ├── nn.po ├── pl.po ├── pt.po ├── pt_BR.po ├── ru.po ├── sk.po ├── sv.po ├── tr.po ├── zh_Hans.po ├── zh_Latn.po └── zh_TW.po ├── package-lock.json ├── package.json ├── resources ├── ffmpeg │ ├── README.md │ ├── darwin-x64 │ │ └── .gitignore │ ├── linux-ia32 │ │ └── .gitignore │ ├── linux-x64 │ │ └── .gitignore │ ├── win32-ia32 │ │ └── .gitignore │ └── win32-x64 │ │ └── .gitignore ├── icons │ ├── 128x128.png │ ├── 16x16.png │ ├── 256x256.png │ ├── 32x32.png │ ├── 64x64.png │ ├── favicon.ico │ ├── sozi.icns │ └── sozi.ico └── install │ ├── darwin │ └── README.md │ ├── linux │ ├── install-local.sh │ ├── install.sh │ ├── sozi.desktop │ └── sozi.png │ └── win32 │ └── README.md ├── src ├── css │ ├── sozi.editor.view.Preview.css │ ├── sozi.editor.view.Properties.css │ ├── sozi.editor.view.Timeline.css │ ├── sozi.editor.view.Toolbar.css │ └── sozi.editor.view.css ├── index-browser.html ├── index-electron.html ├── js │ ├── Controller.js │ ├── Storage.js │ ├── backend │ │ ├── AbstractBackend.js │ │ ├── Electron.js │ │ ├── FileReader.js │ │ ├── GoogleDrive.config.dist.js │ │ ├── GoogleDrive.js │ │ ├── index-browser.js │ │ └── index-electron.js │ ├── editor.js │ ├── exporter │ │ ├── exporter-preload.js │ │ ├── index-browser.js │ │ └── index-electron.js │ ├── i18n.js │ ├── index-electron.js │ ├── model │ │ ├── CameraState.js │ │ ├── Preferences.js │ │ ├── Presentation.js │ │ └── Selection.js │ ├── player.js │ ├── player │ │ ├── Animator.js │ │ ├── Camera.js │ │ ├── FrameList.js │ │ ├── FrameNumber.js │ │ ├── FrameURL.js │ │ ├── Media.js │ │ ├── Player.js │ │ ├── PlayerController.js │ │ ├── Timing.js │ │ ├── TouchGestures.js │ │ └── Viewport.js │ ├── presenter.js │ ├── svg │ │ ├── AiHandler.js │ │ ├── ImpressHandler.js │ │ ├── InkscapeHandler.js │ │ ├── SVGDocumentWrapper.js │ │ └── index.js │ ├── upgrade.js │ └── view │ │ ├── Preview.js │ │ ├── Properties.js │ │ ├── Timeline.js │ │ ├── Toolbar.js │ │ ├── VirtualDOMView.js │ │ └── languages.js └── templates │ ├── player.html │ └── presenter.html ├── tools └── deps.sh └── yarn.lock /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Make sure that you have read the [documentation of Sozi](http://sozi.baierouge.fr), 2 | and especially the [installation instructions](http://sozi.baierouge.fr/pages/20-install.html) 3 | and the [frequently asked questions](http://sozi.baierouge.fr/pages/faq.html) 4 | before reporting an issue. 5 | Remember that this is not a place to ask questions. 6 | If you want to get help on Sozi, you can join the [Sozi community forum](http://sozi.baierouge.fr/community/). 7 | 8 | Describe your problem or propose a new feature in the following sections. 9 | Keep the headings and replace the paragraphs with your own text. 10 | 11 | ### Summary of your problem or feature request 12 | 13 | Write a short description of your problem. 14 | 15 | ### Version of Sozi and other relevant software 16 | 17 | * Which version of Sozi are you using? 18 | * Which drawing software did you use? (examples: Inkscape 0.48, LibreOffice Draw 4.2) 19 | * On which platform? (examples: Windows 7 64-bit, Ubuntu Linux 14.04 32-bit, OS X El Capitan 64-bit) 20 | * If your problem happens when playing a presentation, which browsers have you tried? (examples: Firefox 46, Chrome 52) 21 | 22 | ### Steps to reproduce the problem 23 | 24 | What did you do to make the problem happen? 25 | Give enough details to enable us to reproduce the problem. 26 | 27 | If you are proposing a new feature, what steps should the user follow to use it? 28 | 29 | ### Expected behavior 30 | 31 | What results did you expect? 32 | 33 | ### Observed behavior 34 | 35 | What results did you actually get? 36 | 37 | ### Hints and solutions (optional) 38 | 39 | * If you have taken the time to investigate the problem, tell us what you have found. 40 | * If you have ideas on how to fix the problem, please describe them. 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | node_modules 3 | build 4 | dist 5 | deb 6 | cache 7 | /js/backend/GoogleDrive.config.js 8 | npm-debug.log 9 | *.mo 10 | -------------------------------------------------------------------------------- /README.jsdoc.md: -------------------------------------------------------------------------------- 1 | 2 | ![Sozi logo](sozi-logo-bg.png) 3 | 4 | This is the JavaScript API documentation for Sozi. 5 | 6 | Additional information about the Sozi project can be found at: 7 | 8 | * [The main web site for Sozi, with the user manual and tutorials](https://sozi.baierouge.fr) 9 | * [The source code repository at GitHub](https://github.com/sozi-projects/Sozi) 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![Github Downloads (latest)](https://img.shields.io/github/downloads/sozi-projects/Sozi/latest/total.svg?style=flat-square) 3 | ![Github Downloads (total)](https://img.shields.io/github/downloads/sozi-projects/Sozi/total.svg?style=flat-square) 4 | 5 | Sozi is a presentation tool for SVG documents. 6 | 7 | It is free software distributed under the terms of the 8 | [Mozilla Public License 2.0](https://www.mozilla.org/MPL/2.0/). 9 | 10 | More details can be found on the official web site: 11 | 12 | Building and installing Sozi from sources 13 | ========================================= 14 | 15 | Get the source files 16 | -------------------- 17 | 18 | Clone the repository: 19 | 20 | git clone git://github.com/sozi-projects/Sozi.git 21 | 22 | 23 | Install the build tools and dependencies 24 | ---------------------------------------- 25 | 26 | The following instructions work successfully in Ubuntu 22.04. 27 | 28 | Install [Node.js](http://nodejs.org/) and Gulp. 29 | The build script for Sozi is known to work with Node.js 14 from [Nodesource](https://github.com/nodesource/distributions). 30 | 31 | sudo apt install nodejs 32 | sudo npm install --global gulp-cli 33 | 34 | From the root of the source tree, run: 35 | 36 | npm install 37 | 38 | If you plan to build a Windows executable, also install *wine*. 39 | In Debian/Ubuntu and their derivatives, you can type the following commands. 40 | 41 | dpkg --add-architecture i386 42 | sudo apt update 43 | sudo apt install wine wine32 44 | 45 | If you plan to build Debian packages, install the following additional packages: 46 | 47 | sudo apt install devscripts debhelper 48 | 49 | If you plan to build Redhat packages, install the following additional packages: 50 | 51 | sudo apt install rpm 52 | 53 | If you plan to build Archlinux packages, install the following additional packages: 54 | 55 | sudo apt install libarchive-tools 56 | 57 | The `zip` compression tool must also be installed: 58 | 59 | sudo apt install zip 60 | 61 | Get the binaries for ffmpeg (optional, but video export will not work without them). 62 | Download and unzip the FFMPEG executables to the following folders: 63 | 64 | * Linux 32-bit: `resources/ffmpeg/linux-ia32` 65 | * Linux 64-bit: `resources/ffmpeg/linux-x64` 66 | * Windows 32-bit: `resources/ffmpeg/win32-ia32` 67 | * Windows 64-bit: `resources/ffmpeg/win32-x64` 68 | * MacOS X 64-bit: `resources/ffmpeg/darwin-x64` 69 | 70 | Build 71 | ----- 72 | 73 | To build and run the desktop application without packaging it, 74 | run the following commands from the root of the source tree. 75 | At startup, Sozi will show an error notification that can be ignored. 76 | 77 | ``` 78 | gulp 79 | npm start 80 | ``` 81 | 82 | To build and package the desktop application for all platforms, do: 83 | 84 | ``` 85 | gulp all 86 | ``` 87 | 88 | After a successful build, you will get a `build/dist` folder that contains the 89 | generated application archives for each platform. 90 | 91 | Helping debug Sozi 92 | ================== 93 | 94 | While Sozi is running, press `F12` to open the developer tools. 95 | Check the *Console* tab for error messages. 96 | 97 | Some environment variables will enable debugging features in Sozi. 98 | When running Sozi from the command line, you can add one 99 | or more variable assignments like this: 100 | 101 | ``` 102 | SOME_VAR=1 SOME_OTHER_VAR=1 sozi my-presentation.svg 103 | ``` 104 | 105 | Where `SOME_VAR` and `SOME_OTHER_VAR` are variable names from the 106 | first column of this table: 107 | 108 | | Variable | Effect | 109 | |:---------------------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------| 110 | | `ELECTRON_ENABLE_LOGGING` | Display JavaScript console messages in the current terminal window. | 111 | | `SOZI_DEVTOOLS` | Open the developer tools immediately. This can be useful if `F12` has no effect or when you want to debug events that happen at startup. | 112 | 113 | -------------------------------------------------------------------------------- /config/csslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | "box-model", 4 | "universal-selector", 5 | "box-sizing", 6 | "qualified-headings", 7 | "adjoining-classes", 8 | "order-alphabetical", 9 | "fallback-colors", 10 | "ids" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /config/eslintrc.mjs: -------------------------------------------------------------------------------- 1 | import jsdoc from "eslint-plugin-jsdoc"; 2 | import globals from "globals"; 3 | import path from "node:path"; 4 | import { fileURLToPath } from "node:url"; 5 | import js from "@eslint/js"; 6 | import { FlatCompat } from "@eslint/eslintrc"; 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | const compat = new FlatCompat({ 11 | baseDirectory: __dirname, 12 | recommendedConfig: js.configs.recommended, 13 | allConfig: js.configs.all 14 | }); 15 | 16 | export default [...compat.extends("eslint:recommended", "plugin:jsdoc/recommended"), { 17 | languageOptions: { 18 | globals: { 19 | ...globals.browser, 20 | ...globals.node, 21 | }, 22 | 23 | ecmaVersion: 8, 24 | sourceType: "module", 25 | }, 26 | 27 | settings: { 28 | jsdoc: { 29 | tagNamePreference: { 30 | augments: "extends", 31 | }, 32 | }, 33 | }, 34 | 35 | rules: { 36 | "no-unused-vars": ["warn"], 37 | "no-prototype-builtins": ["warn"], 38 | 39 | "jsdoc/require-jsdoc": ["error", { 40 | publicOnly: true, 41 | checkSetters: false, 42 | 43 | require: { 44 | ClassDeclaration: true, 45 | MethodDefinition: true, 46 | }, 47 | }], 48 | 49 | "jsdoc/multiline-blocks": ["warn", { 50 | noZeroLineText: false, 51 | }], 52 | 53 | "jsdoc/tag-lines": ["off"], 54 | }, 55 | }]; 56 | -------------------------------------------------------------------------------- /config/jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "templates": { 3 | "cleverLinks": false, 4 | "navigation": [ 5 | { 6 | "label": "Github", 7 | "href": "https://github.com/sozi-projects/Sozi.git" 8 | }, 9 | { 10 | "label": "Home page", 11 | "href": "https://sozi.baierouge.fr" 12 | } 13 | ], 14 | "default": { 15 | "staticFiles": { 16 | "include": [ 17 | "doc/sozi-logo-bg.png" 18 | ] 19 | } 20 | } 21 | }, 22 | "tags": { 23 | "allowUnknownTags": ["category"] 24 | }, 25 | "plugins": [ 26 | "plugins/markdown" 27 | ], 28 | "opts": { 29 | "destination": "build/api", 30 | "readme": "README.jsdoc.md" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /doc/presentations/2014-02-01-FOSDEM/.gitignore: -------------------------------------------------------------------------------- 1 | Images 2 | Drawings 3 | -------------------------------------------------------------------------------- /doc/presentations/Website/this-is-not-a-slideshow.fast.sozi.json: -------------------------------------------------------------------------------- 1 | {"aspectWidth":4,"aspectHeight":3,"frames":[{"frameId":"frame1","title":"This","timeoutMs":1500,"timeoutEnable":true,"transitionDurationMs":1000,"showInFrameList":true,"showFrameNumber":true,"layerProperties":{"layer1":{"link":false,"referenceElementId":"rect2993","referenceElementAuto":false,"transitionTimingFunction":"easeInOut","transitionRelativeZoom":0,"transitionPathId":""},"__sozi_auto__":{"link":false,"referenceElementId":"rect2993","referenceElementAuto":false,"transitionTimingFunction":"easeInOut","transitionRelativeZoom":0,"transitionPathId":""}},"cameraStates":{"layer1":{"cx":287.0755920410156,"cy":553.9180297851562,"width":80.0000028524783,"height":65.00000231763862,"opacity":1,"angle":59.75085839755638,"clipped":true,"clipXOffset":0,"clipYOffset":0,"clipWidthFactor":1,"clipHeightFactor":1},"__sozi_auto__":{"cx":287.0755920410156,"cy":553.9180297851562,"width":80.0000028524783,"height":65.00000231763862,"opacity":1,"angle":59.75085839755638,"clipped":true,"clipXOffset":0,"clipYOffset":0,"clipWidthFactor":1,"clipHeightFactor":1}},"cameraOffsets":{"layer1":{"deltaX":0,"deltaY":0,"widthFactor":0.9999999999999997,"heightFactor":0.9999999999999998,"deltaAngle":0},"__sozi_auto__":{"deltaX":0,"deltaY":0,"widthFactor":0.9999999999999997,"heightFactor":0.9999999999999998,"deltaAngle":0}}},{"frameId":"frame2","title":"is","timeoutMs":1500,"timeoutEnable":true,"transitionDurationMs":0,"showInFrameList":true,"showFrameNumber":true,"layerProperties":{"layer1":{"link":false,"referenceElementId":"rect3765","referenceElementAuto":false,"transitionTimingFunction":"linear","transitionRelativeZoom":0,"transitionPathId":""},"__sozi_auto__":{"link":false,"referenceElementId":"rect3765","referenceElementAuto":false,"transitionTimingFunction":"linear","transitionRelativeZoom":0,"transitionPathId":""}},"cameraStates":{"layer1":{"cx":306.840087890625,"cy":603.2578735351562,"width":39.99999904352086,"height":64.9999984457214,"opacity":1,"angle":60.09817319511137,"clipped":true,"clipXOffset":0,"clipYOffset":0,"clipWidthFactor":1,"clipHeightFactor":1},"__sozi_auto__":{"cx":306.840087890625,"cy":603.2578735351562,"width":39.99999904352086,"height":64.9999984457214,"opacity":1,"angle":60.09817319511137,"clipped":true,"clipXOffset":0,"clipYOffset":0,"clipWidthFactor":1,"clipHeightFactor":1}},"cameraOffsets":{"layer1":{"deltaX":0,"deltaY":0,"widthFactor":0.9999999999999998,"heightFactor":1,"deltaAngle":0},"__sozi_auto__":{"deltaX":0,"deltaY":0,"widthFactor":0.9999999999999998,"heightFactor":1,"deltaAngle":0}}},{"frameId":"frame3","title":"not","timeoutMs":1500,"timeoutEnable":true,"transitionDurationMs":0,"showInFrameList":true,"showFrameNumber":true,"layerProperties":{"layer1":{"link":false,"referenceElementId":"rect3767","referenceElementAuto":false,"transitionTimingFunction":"linear","transitionRelativeZoom":0,"transitionPathId":""},"__sozi_auto__":{"link":false,"referenceElementId":"rect3767","referenceElementAuto":false,"transitionTimingFunction":"linear","transitionRelativeZoom":0,"transitionPathId":""}},"cameraStates":{"layer1":{"cx":331.13775634765625,"cy":640.1878662109375,"width":60.000000152553916,"height":65.00000016526674,"opacity":1,"angle":59.408132506329515,"clipped":true,"clipXOffset":0,"clipYOffset":0,"clipWidthFactor":1,"clipHeightFactor":1},"__sozi_auto__":{"cx":331.13775634765625,"cy":640.1878662109375,"width":60.000000152553916,"height":65.00000016526674,"opacity":1,"angle":59.408132506329515,"clipped":true,"clipXOffset":0,"clipYOffset":0,"clipWidthFactor":1,"clipHeightFactor":1}},"cameraOffsets":{"layer1":{"deltaX":0,"deltaY":0,"widthFactor":1,"heightFactor":1,"deltaAngle":0},"__sozi_auto__":{"deltaX":0,"deltaY":0,"widthFactor":1,"heightFactor":1,"deltaAngle":0}}},{"frameId":"frame4","title":"a","timeoutMs":1500,"timeoutEnable":true,"transitionDurationMs":0,"showInFrameList":true,"showFrameNumber":true,"layerProperties":{"layer1":{"link":false,"referenceElementId":"rect3769","referenceElementAuto":false,"transitionTimingFunction":"linear","transitionRelativeZoom":0,"transitionPathId":""},"__sozi_auto__":{"link":false,"referenceElementId":"rect3769","referenceElementAuto":false,"transitionTimingFunction":"linear","transitionRelativeZoom":0,"transitionPathId":""}},"cameraStates":{"layer1":{"cx":353.137451171875,"cy":679.9031982421875,"width":35.00000048005872,"height":65.00000089153762,"opacity":1,"angle":61.56013717588014,"clipped":true,"clipXOffset":0,"clipYOffset":0,"clipWidthFactor":1,"clipHeightFactor":1},"__sozi_auto__":{"cx":353.137451171875,"cy":679.9031982421875,"width":35.00000048005872,"height":65.00000089153762,"opacity":1,"angle":61.56013717588014,"clipped":true,"clipXOffset":0,"clipYOffset":0,"clipWidthFactor":1,"clipHeightFactor":1}},"cameraOffsets":{"layer1":{"deltaX":0,"deltaY":0,"widthFactor":1,"heightFactor":1,"deltaAngle":0},"__sozi_auto__":{"deltaX":0,"deltaY":0,"widthFactor":1,"heightFactor":1,"deltaAngle":0}}},{"frameId":"frame5","title":"slideshow","timeoutMs":2000,"timeoutEnable":true,"transitionDurationMs":0,"showInFrameList":true,"showFrameNumber":true,"layerProperties":{"layer1":{"link":false,"referenceElementId":"rect3771","referenceElementAuto":false,"transitionTimingFunction":"linear","transitionRelativeZoom":0,"transitionPathId":""},"__sozi_auto__":{"link":false,"referenceElementId":"rect3771","referenceElementAuto":false,"transitionTimingFunction":"linear","transitionRelativeZoom":0,"transitionPathId":""}},"cameraStates":{"layer1":{"cx":393.33538818359375,"cy":763.5491943359375,"width":166.10192425958073,"height":67.4789029157575,"opacity":1,"angle":64.0644118055956,"clipped":true,"clipXOffset":0,"clipYOffset":0,"clipWidthFactor":1,"clipHeightFactor":1},"__sozi_auto__":{"cx":393.33538818359375,"cy":763.5491943359375,"width":166.10192425958073,"height":67.4789029157575,"opacity":1,"angle":64.0644118055956,"clipped":true,"clipXOffset":0,"clipYOffset":0,"clipWidthFactor":1,"clipHeightFactor":1}},"cameraOffsets":{"layer1":{"deltaX":0,"deltaY":0,"widthFactor":0.9999999999999998,"heightFactor":0.9999999999999998,"deltaAngle":0},"__sozi_auto__":{"deltaX":0,"deltaY":0,"widthFactor":0.9999999999999998,"heightFactor":0.9999999999999998,"deltaAngle":0}}},{"frameId":"frame6","title":"This is not a slideshow","timeoutMs":1000,"timeoutEnable":true,"transitionDurationMs":1000,"showInFrameList":true,"showFrameNumber":true,"layerProperties":{"layer1":{"link":false,"referenceElementId":"rect4308","referenceElementAuto":false,"transitionTimingFunction":"easeInOut","transitionRelativeZoom":0,"transitionPathId":""},"__sozi_auto__":{"link":false,"referenceElementId":"rect4308","referenceElementAuto":false,"transitionTimingFunction":"easeInOut","transitionRelativeZoom":0,"transitionPathId":""}},"cameraStates":{"layer1":{"cx":321.0634765625,"cy":699.0166015625,"width":460.00001374732034,"height":155.00000463224924,"opacity":1,"angle":61.12337806045174,"clipped":true,"clipXOffset":0,"clipYOffset":0,"clipWidthFactor":1,"clipHeightFactor":1},"__sozi_auto__":{"cx":321.0634765625,"cy":699.0166015625,"width":460.00001374732034,"height":155.00000463224924,"opacity":1,"angle":61.12337806045174,"clipped":true,"clipXOffset":0,"clipYOffset":0,"clipWidthFactor":1,"clipHeightFactor":1}},"cameraOffsets":{"layer1":{"deltaX":0,"deltaY":0,"widthFactor":1,"heightFactor":1,"deltaAngle":0},"__sozi_auto__":{"deltaX":0,"deltaY":0,"widthFactor":1,"heightFactor":1,"deltaAngle":0}}},{"frameId":"frame7","title":"This","timeoutMs":1500,"timeoutEnable":true,"transitionDurationMs":1000,"showInFrameList":true,"showFrameNumber":true,"layerProperties":{"layer1":{"link":false,"referenceElementId":"rect3773","referenceElementAuto":false,"transitionTimingFunction":"easeInOut","transitionRelativeZoom":0,"transitionPathId":""},"__sozi_auto__":{"link":false,"referenceElementId":"rect3773","referenceElementAuto":false,"transitionTimingFunction":"easeInOut","transitionRelativeZoom":0,"transitionPathId":""}},"cameraStates":{"layer1":{"cx":711.5264282226562,"cy":574.296875,"width":194.01286501337967,"height":113.17413438239755,"opacity":1,"angle":-34.17281705332668,"clipped":true,"clipXOffset":0,"clipYOffset":0,"clipWidthFactor":1,"clipHeightFactor":1},"__sozi_auto__":{"cx":711.5264282226562,"cy":574.296875,"width":194.01286501337967,"height":113.17413438239755,"opacity":1,"angle":-34.17281705332668,"clipped":true,"clipXOffset":0,"clipYOffset":0,"clipWidthFactor":1,"clipHeightFactor":1}},"cameraOffsets":{"layer1":{"deltaX":0,"deltaY":0,"widthFactor":1,"heightFactor":1,"deltaAngle":0},"__sozi_auto__":{"deltaX":0,"deltaY":0,"widthFactor":1,"heightFactor":1,"deltaAngle":0}}},{"frameId":"frame8","title":"is","timeoutMs":1500,"timeoutEnable":true,"transitionDurationMs":1000,"showInFrameList":true,"showFrameNumber":true,"layerProperties":{"layer1":{"link":false,"referenceElementId":"rect4875","referenceElementAuto":false,"transitionTimingFunction":"easeInOut","transitionRelativeZoom":0,"transitionPathId":""},"__sozi_auto__":{"link":false,"referenceElementId":"rect4875","referenceElementAuto":false,"transitionTimingFunction":"easeInOut","transitionRelativeZoom":0,"transitionPathId":""}},"cameraStates":{"layer1":{"cx":826.8518676757812,"cy":478.66595458984375,"width":188.10803885127046,"height":109.72967915738137,"opacity":1,"angle":-112.64846978046535,"clipped":true,"clipXOffset":0,"clipYOffset":0,"clipWidthFactor":1,"clipHeightFactor":1},"__sozi_auto__":{"cx":826.8518676757812,"cy":478.66595458984375,"width":188.10803885127046,"height":109.72967915738137,"opacity":1,"angle":-112.64846978046535,"clipped":true,"clipXOffset":0,"clipYOffset":0,"clipWidthFactor":1,"clipHeightFactor":1}},"cameraOffsets":{"layer1":{"deltaX":0,"deltaY":0,"widthFactor":0.9999999999999999,"heightFactor":0.9999999999999999,"deltaAngle":0},"__sozi_auto__":{"deltaX":0,"deltaY":0,"widthFactor":0.9999999999999999,"heightFactor":0.9999999999999999,"deltaAngle":0}}},{"frameId":"frame9","title":"Sozi","timeoutMs":5000,"timeoutEnable":true,"transitionDurationMs":1000,"showInFrameList":true,"showFrameNumber":true,"layerProperties":{"layer1":{"link":false,"referenceElementId":"rect3787","referenceElementAuto":false,"transitionTimingFunction":"easeInOut","transitionRelativeZoom":0,"transitionPathId":""},"__sozi_auto__":{"link":false,"referenceElementId":"rect3787","referenceElementAuto":false,"transitionTimingFunction":"easeInOut","transitionRelativeZoom":0,"transitionPathId":""}},"cameraStates":{"layer1":{"cx":530,"cy":532.9920654296875,"width":1060.0001220703125,"height":1020,"opacity":1,"angle":0,"clipped":true,"clipXOffset":0,"clipYOffset":0,"clipWidthFactor":1,"clipHeightFactor":1},"__sozi_auto__":{"cx":530,"cy":532.9920654296875,"width":1060.0001220703125,"height":1020,"opacity":1,"angle":0,"clipped":true,"clipXOffset":0,"clipYOffset":0,"clipWidthFactor":1,"clipHeightFactor":1}},"cameraOffsets":{"layer1":{"deltaX":0,"deltaY":0,"widthFactor":1,"heightFactor":1,"deltaAngle":0},"__sozi_auto__":{"deltaX":0,"deltaY":0,"widthFactor":1,"heightFactor":1,"deltaAngle":0}}}],"elementsToHide":["rect2993","rect3765","rect3767","rect3769","rect3771","rect4308","rect3773","rect4875","rect3787"],"selectedFrames":["frame9"],"selectedLayers":["layer1","__sozi_auto__"],"editableLayers":[]} -------------------------------------------------------------------------------- /doc/sozi-logo-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sozi-projects/Sozi/67f2ce434dc8f23d17afc99e87503361f5c39d61/doc/sozi-logo-bg.png -------------------------------------------------------------------------------- /doc/sozi-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 20 | 28 | 38 | 42 | 48 | 58 | 64 | 74 | 92 | 93 | 115 | 117 | 118 | 120 | image/svg+xml 121 | 123 | 124 | 125 | 126 | 127 | 132 | 137 | 143 | 148 | 154 | 160 | 166 | 172 | 178 | 183 | 184 | 185 | -------------------------------------------------------------------------------- /doc/startup.plantuml: -------------------------------------------------------------------------------- 1 | 2 | @startuml 3 | 4 | activate editor 5 | editor -> Presentation : init 6 | 7 | editor -> Selection : init 8 | 9 | editor -> Viewport : init 10 | 11 | editor -> Player : init 12 | activate Player 13 | Player -> Viewport : addListener("click", "dragStart", "userChangeState") 14 | Player --> editor 15 | deactivate Player 16 | 17 | editor -> Controller : init 18 | activate Controller 19 | Controller -> Controller : addListener("repaint") 20 | Controller --> editor 21 | deactivate Controller 22 | 23 | editor -> Preview : init 24 | activate Preview 25 | Preview -> Controller : addListener("loadSVG") 26 | Preview -> Viewport : addListener("mouseDown") 27 | Preview --> editor 28 | deactivate Preview 29 | 30 | editor -> Properties : init 31 | activate Properties 32 | Properties -> Controller : addListener("repaint") 33 | Properties --> editor 34 | deactivate Properties 35 | 36 | editor -> Toolbar : init 37 | activate Toolbar 38 | Toolbar -> Controller : addListener("repaint") 39 | Toolbar --> editor 40 | deactivate Toolbar 41 | 42 | editor -> Timeline : init 43 | activate Timeline 44 | Timeline -> Controller : addListener("repaint", "ready") 45 | Timeline --> editor 46 | deactivate Timeline 47 | 48 | editor -> Storage : init 49 | activate Storage 50 | Storage -> Controller : addListener("presentationChange", "editorStateChange") 51 | Storage -> Electron : init 52 | activate Electron 53 | Electron -> Electron : loadConfiguration 54 | opt No file argument or file cannot be opened 55 | Electron -> Electron : openFileChooser 56 | end 57 | Electron -> Electron : load(svgFileName) 58 | activate Electron 59 | Electron -> fs : readFile(svgFileName) 60 | activate fs 61 | deactivate Electron 62 | Electron --> Storage 63 | deactivate Electron 64 | Storage -> Electron : addListener("load", "change") 65 | Storage --> editor 66 | deactivate Storage 67 | deactivate editor 68 | 69 | fs --> Electron : [callback] 70 | deactivate fs 71 | activate Electron 72 | Electron -> fs : watcher(svgFileName) 73 | activate fs 74 | create svgWatcher 75 | fs -> svgWatcher : new 76 | fs --> Electron 77 | deactivate fs 78 | Electron -> svgWatcher : addListener("change") 79 | 80 | Electron -> Storage : emit("load", svgFileName, svgData) 81 | deactivate Electron 82 | 83 | activate Storage 84 | Storage -> SVGDocumentWrapper : initFromString(svgData) 85 | Storage -> Controller : setSVGDocument(SVGDocumentWrapper) 86 | activate Controller 87 | Controller -> Presentation : init 88 | Controller -> Presentation : setSVGDocument(SVGDocumentWrapper) 89 | Controller -> Preview : emit("loadSVG") 90 | activate Preview 91 | Preview -> Viewport : addListener("click", "userChangeState") 92 | Preview -> Controller : addListener("repaint") 93 | Preview -> Viewport : onLoad 94 | deactivate Preview 95 | Controller -> Presentation : setInitialCameraState 96 | Controller --> Storage 97 | deactivate Controller 98 | Storage -> Controller : once("ready") 99 | Storage -> Storage : openJSONFile(jsonFileName) 100 | activate Storage 101 | Storage -> Electron : find(jsonFileName) 102 | deactivate Storage 103 | deactivate Storage 104 | 105 | activate Electron 106 | Electron --> Storage : [callback] 107 | deactivate Electron 108 | 109 | activate Storage 110 | alt File was found 111 | Storage -> Electron : load(jsonFileName) 112 | activate Electron 113 | Electron -> fs : readFile(jsonFileName) 114 | activate fs 115 | deactivate Electron 116 | 117 | fs --> Electron : [callback] 118 | activate Electron 119 | Electron -> fs : watcher(jsonFileName) 120 | activate fs 121 | create jsonWatcher 122 | fs -> jsonWatcher : new 123 | deactivate fs 124 | Electron -> jsonWatcher : addListener("change") 125 | Electron -> Storage : emit("load", jsonFileName, jsonData) 126 | activate Storage 127 | Storage -> Storage : loadJSONData(jsonData) 128 | activate Storage 129 | Storage -> Presentation : fromStorable(jsonData) 130 | Storage -> Timeline : fromStorable(jsonData) 131 | Storage -> Selection : fromStorable(jsonData) 132 | Storage -> Controller : onLoad 133 | activate Controller 134 | Controller -> Electron : loadPreferences 135 | Controller -> Selection : addFrame 136 | Controller -> Player : jumpToFrame 137 | Controller -> Controller : updateCameraSelection 138 | Controller -> Timeline : emit("ready") 139 | Controller -> Storage : emit("ready") 140 | activate Storage 141 | Storage -> Storage : createHTMLFile(htmlFileName) 142 | Storage --> Controller 143 | deactivate Storage 144 | Controller -> Controller : applyPreferences 145 | Controller --> Storage 146 | deactivate Controller 147 | deactivate Storage 148 | Storage -> Storage : autosaveJSON(jsonFileName) 149 | activate Storage 150 | Storage -> Electron : autosave(jsonFileName) 151 | Storage -> Electron : addListener("save") 152 | deactivate Storage 153 | deactivate Storage 154 | deactivate Electron 155 | 156 | else File was not found 157 | Storage -> Electron : create(jsonFileName) 158 | activate Electron 159 | Electron -> Storage : [callback] 160 | activate Storage 161 | Storage -> Storage : autosaveJSON(jsonFileName) 162 | Storage -> Electron : autosave(jsonFileName) 163 | Storage -> Electron : addListener("save") 164 | Storage --> Electron 165 | deactivate Storage 166 | Storage --> Electron 167 | deactivate Electron 168 | Storage -> Controller : onLoad 169 | end 170 | deactivate Storage 171 | 172 | @enduml 173 | -------------------------------------------------------------------------------- /doc/startup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sozi-projects/Sozi/67f2ce434dc8f23d17afc99e87503361f5c39d61/doc/startup.png -------------------------------------------------------------------------------- /extras/media-inkscape-0.92/sozi_extras_media.inx: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | <_name>Add video or audio 7 | sozi.extras.media 8 | inkex.py 9 | sozi_extras_media.py 10 | 11 | video 12 | audio 13 | 14 | 640 15 | 480 16 | video/ogg 17 | filename.ogv 18 | false 19 | undefined 20 | undefined 21 | false 22 | false 23 | 24 | all 25 | 26 | 27 | 28 | 29 | 32 | 33 | -------------------------------------------------------------------------------- /extras/media-inkscape-0.92/sozi_extras_media.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | # Sozi - A presentation tool using the SVG standard 7 | 8 | # These lines are only needed if you don't put the script directly into 9 | # the installation directory 10 | import sys 11 | # Unix 12 | sys.path.append('/usr/share/inkscape/extensions') 13 | # OS X 14 | sys.path.append('/Applications/Inkscape.app/Contents/Resources/extensions') 15 | # Windows 16 | sys.path.append('C:\Program Files\Inkscape\share\extensions') 17 | 18 | # We will use the inkex module with the predefined Effect base class. 19 | import inkex 20 | 21 | class SoziExtrasMedia(inkex.Effect): 22 | 23 | NS_URI = u"http://sozi.baierouge.fr" 24 | 25 | SOZI_VERSION = "15.04" 26 | 27 | def __init__(self): 28 | inkex.Effect.__init__(self) 29 | self.OptionParser.add_option('-E', '--element', action = 'store', 30 | type = 'string', dest = 'element', default = 'video', 31 | help = 'Media element (video | audio)') 32 | self.OptionParser.add_option('-W', '--width', action = 'store', 33 | type = 'int', dest = 'width', default = 640, 34 | help = 'Media region width') 35 | self.OptionParser.add_option('-H', '--height', action = 'store', 36 | type = 'int', dest = 'height', default = 480, 37 | help = 'Media region height') 38 | self.OptionParser.add_option('-T', '--type', action = 'store', 39 | type = 'string', dest = 'type', default = 'video/ogg', 40 | help = 'Media MIME type') 41 | self.OptionParser.add_option('-S', '--src', action = 'store', 42 | type = 'string', dest = 'src', default = '', 43 | help = 'Media file name') 44 | self.OptionParser.add_option('-A', '--auto', action = 'store', 45 | type = 'string', dest = 'auto', default = 'false', 46 | help = 'Play automatically in Sozi frame') 47 | self.OptionParser.add_option('-F', '--start-frame', action = 'store', 48 | type = 'string', dest = 'start_frame', 49 | help = 'Start playing when entering frame (id)') 50 | self.OptionParser.add_option('-G', '--stop-frame', action = 'store', 51 | type = 'string', dest = 'stop_frame', 52 | help = 'Stop playing when entering frame (id)') 53 | self.OptionParser.add_option('-L', '--loop', action = 'store', 54 | type = 'string', dest = 'loop', default = 'false', 55 | help = 'Loop') 56 | self.OptionParser.add_option('-C', '--controls', action = 'store', 57 | type = 'string', dest = 'controls', default = 'false', 58 | help = 'Show controls') 59 | inkex.NSS[u"sozi"] = SoziExtrasMedia.NS_URI 60 | 61 | 62 | def effect(self): 63 | # If an element is selected, check if it is of the form 64 | # or 65 | # and get a reference to the element. 66 | rect = None 67 | if len(self.selected) != 0: 68 | elt = self.selected.values()[0] 69 | if elt.tag == inkex.addNS("g", "svg") and len(elt) > 0 and elt[0].tag == inkex.addNS("rect", "svg") and len(elt[0]) > 0 and elt[0][0].tag == inkex.addNS(self.options.element, "sozi"): 70 | rect = elt[0] 71 | 72 | # If no was selected, create one with the dimensions 73 | # provided by the user and insert it into a element. 74 | if rect == None: 75 | rect = inkex.etree.Element("rect") 76 | rect.set("x", unicode(self.view_center[0] - self.options.width / 2)) 77 | rect.set("y", unicode(self.view_center[1] - self.options.height / 2)) 78 | rect.set("width", unicode(self.options.width)) 79 | rect.set("height", unicode(self.options.height)) 80 | rect.set("stroke", "none") 81 | rect.set("fill", "#aaa") 82 | 83 | g = inkex.etree.Element("g") 84 | g.append(rect) 85 | 86 | self.current_layer.append(g) 87 | 88 | # Add a or element inside the 89 | v = inkex.etree.Element(inkex.addNS(self.options.element, "sozi")) 90 | v.set(inkex.addNS("type", "sozi"), self.options.type.decode("utf-8")) 91 | v.set(inkex.addNS("src", "sozi"), self.options.src.decode("utf-8")) 92 | v.set(inkex.addNS("loop", "sozi"), self.options.loop.decode("utf-8")) 93 | v.set(inkex.addNS("controls", "sozi"), self.options.controls.decode("utf-8")) 94 | 95 | # If the media is set to autoplay, add "start-frame" and "stop-frame" attributes 96 | if self.options.auto == "true": 97 | v.set(inkex.addNS("start-frame", "sozi"), self.options.start_frame.decode("utf-8")) 98 | v.set(inkex.addNS("stop-frame", "sozi"), self.options.stop_frame.decode("utf-8")) 99 | 100 | rect.append(v) 101 | 102 | 103 | # Create effect instance 104 | effect = SoziExtrasMedia() 105 | effect.affect() 106 | -------------------------------------------------------------------------------- /extras/media-inkscape-1.0/sozi_extras_media.inx: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | Add video or audio 7 | sozi.extras.media 8 | sozi_extras_media.py 9 | 10 | video 11 | audio 12 | 13 | 640 14 | 480 15 | video/ogg 16 | filename.ogv 17 | false 18 | undefined 19 | undefined 20 | false 21 | false 22 | 23 | all 24 | 25 | 26 | 27 | 28 | 31 | 32 | -------------------------------------------------------------------------------- /extras/media-inkscape-1.0/sozi_extras_media.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | # Sozi - A presentation tool using the SVG standard 7 | 8 | # These lines are only needed if you don't put the script directly into 9 | # the installation directory 10 | import sys 11 | # Unix 12 | sys.path.append('/usr/share/inkscape/extensions') 13 | # OS X 14 | sys.path.append('/Applications/Inkscape.app/Contents/Resources/extensions') 15 | # Windows 16 | sys.path.append('C:\Program Files\Inkscape\share\extensions') 17 | 18 | # We will use the inkex module with the predefined Effect base class. 19 | import inkex 20 | from lxml import etree 21 | 22 | class SoziExtrasMedia(inkex.Effect): 23 | 24 | NS_URI = u"http://sozi.baierouge.fr" 25 | 26 | def __init__(self): 27 | super().__init__() 28 | self.arg_parser.add_argument('-E', '--element', action = 'store', 29 | type = str, dest = 'element', default = 'video', 30 | help = 'Media element (video | audio)') 31 | self.arg_parser.add_argument('-W', '--width', action = 'store', 32 | type = int, dest = 'width', default = 640, 33 | help = 'Media region width') 34 | self.arg_parser.add_argument('-H', '--height', action = 'store', 35 | type = int, dest = 'height', default = 480, 36 | help = 'Media region height') 37 | self.arg_parser.add_argument('-T', '--type', action = 'store', 38 | type = str, dest = 'type', default = 'video/ogg', 39 | help = 'Media MIME type') 40 | self.arg_parser.add_argument('-S', '--src', action = 'store', 41 | type = str, dest = 'src', default = '', 42 | help = 'Media file name') 43 | self.arg_parser.add_argument('-A', '--auto', action = 'store', 44 | type = str, dest = 'auto', default = 'false', 45 | help = 'Play automatically in Sozi frame') 46 | self.arg_parser.add_argument('-F', '--start-frame', action = 'store', 47 | type = str, dest = 'start_frame', 48 | help = 'Start playing when entering frame (id)') 49 | self.arg_parser.add_argument('-G', '--stop-frame', action = 'store', 50 | type = str, dest = 'stop_frame', 51 | help = 'Stop playing when entering frame (id)') 52 | self.arg_parser.add_argument('-L', '--loop', action = 'store', 53 | type = str, dest = 'loop', default = 'false', 54 | help = 'Loop') 55 | self.arg_parser.add_argument('-C', '--controls', action = 'store', 56 | type = str, dest = 'controls', default = 'false', 57 | help = 'Show controls') 58 | inkex.NSS[u"sozi"] = SoziExtrasMedia.NS_URI 59 | 60 | 61 | def effect(self): 62 | # Create a group. 63 | group = etree.Element("g") 64 | 65 | # Create a rectangle with the dimensions provided by the user. 66 | rect = etree.Element("rect") 67 | rect.set("x", str(self.svg.view_center[0] - self.options.width / 2)) 68 | rect.set("y", str(self.svg.view_center[1] - self.options.height / 2)) 69 | rect.set("width", str(self.options.width)) 70 | rect.set("height", str(self.options.height)) 71 | rect.set("stroke", "none") 72 | rect.set("fill", "#aaa") 73 | 74 | # Create a or element. 75 | media = etree.Element(inkex.addNS(self.options.element, "sozi")) 76 | media.set(inkex.addNS("type", "sozi"), self.options.type) 77 | media.set(inkex.addNS("src", "sozi"), self.options.src) 78 | media.set(inkex.addNS("loop", "sozi"), self.options.loop) 79 | media.set(inkex.addNS("controls", "sozi"), self.options.controls) 80 | 81 | # If the media is set to autoplay, add "start-frame" and "stop-frame" attributes 82 | if self.options.auto == "true": 83 | media.set(inkex.addNS("start-frame", "sozi"), self.options.start_frame) 84 | media.set(inkex.addNS("stop-frame", "sozi"), self.options.stop_frame) 85 | 86 | # Add the subtree group -> rect -> media to the current layer. 87 | rect.append(media) 88 | group.append(rect) 89 | self.svg.get_current_layer().append(group) 90 | 91 | 92 | # Create effect instance 93 | effect = SoziExtrasMedia() 94 | effect.run() 95 | -------------------------------------------------------------------------------- /extras/texts2paths/texts2paths.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | from optparse import OptionParser 8 | from lxml import etree 9 | import subprocess, shutil, sys, os 10 | 11 | if __name__ == '__main__': 12 | option_parser = OptionParser() 13 | 14 | option_parser.description = "Convert all texts to paths" 15 | option_parser.usage = "texts2paths.py [options] input_file.svg" 16 | 17 | option_parser.add_option("-o", "--output", type="string", dest="output", 18 | help="The target SVG file name") 19 | 20 | options, args = option_parser.parse_args() 21 | 22 | if len(args) == 0: 23 | option_parser.print_usage(sys.stderr) 24 | sys.exit() 25 | 26 | # Set input and output file name 27 | input_file_name = args[0] 28 | 29 | if options.output is not None: 30 | output_file_name = options.output 31 | else: 32 | output_file_name = os.path.basename(input_file_name) 33 | output_file_name = os.path.splitext(output_file_name)[0] + "-texts2paths.svg" 34 | 35 | shutil.copy(input_file_name, output_file_name) 36 | 37 | # Get text elements from the original document 38 | input_file = open(input_file_name) 39 | tree = etree.parse(input_file) 40 | texts = tree.getroot().xpath("//*[local-name() = $name]", name="text") 41 | input_file.close() 42 | 43 | # Use Inkscape to convert each text element to a path 44 | command = ["inkscape", "--batch-process"] 45 | actions = ["FileVacuum"] 46 | for t in texts: 47 | actions += ["EditDeselect", "select-by-id:" + t.get("id"), "ObjectToPath"] 48 | actions += ["FileSave"] 49 | command += ["--actions=" + ";".join(actions), output_file_name] 50 | 51 | subprocess.call(command) 52 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ES6", 4 | "target": "ES6", 5 | "checkJs": false 6 | }, 7 | "exclude": [ 8 | "node_modules" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sozi", 3 | "description": "A zooming presentation editor", 4 | "author": "Guillaume Savaton ", 5 | "homepage": "http://sozi.baierouge.fr", 6 | "version": "24.11.17", 7 | "license": "MPL-2.0", 8 | "repository": { 9 | "type": "git", 10 | "url": "http://github.com/sozi-projects/Sozi.git" 11 | }, 12 | "scripts": { 13 | "eslint": "eslint --config config/eslintrc.mjs src/js/", 14 | "csslint": "csslint --config=config/csslintrc.json src/css/", 15 | "build": "gulp", 16 | "start": "electron build/electron" 17 | }, 18 | "dependencies": { 19 | "@electron/remote": "^2.0.8", 20 | "@fontsource-variable/inter": "^5.1.0", 21 | "core-js": "^3.16.0", 22 | "electron-app-settings": "^1.3.1", 23 | "fork-awesome": "^1.2.0", 24 | "inferno": "^8.0.3", 25 | "inferno-hyperscript": "^8.0.3", 26 | "jed": "^1.1.0", 27 | "nunjucks": "^3.2.4", 28 | "officegen": "^0.6.5", 29 | "pdf-lib": "^1.16.0", 30 | "screenfull": "5.2", 31 | "tmp": "^0.2.1" 32 | }, 33 | "devDependencies": { 34 | "@babel/core": "^7.14.8", 35 | "@babel/preset-env": "^7.14.8", 36 | "browserify": "^17.0.0", 37 | "csslint": "^1.0.5", 38 | "electron": "^33.0.2", 39 | "electron-builder": "^25.1.8", 40 | "envify": "^4.1.0", 41 | "eslint": "^9.14.0", 42 | "eslint-plugin-jsdoc": "^50.4.3", 43 | "fancy-log": "^2.0.0", 44 | "globals": "^15.11.0", 45 | "gulp": "^5.0.0", 46 | "gulp-babel": "^8.0.0", 47 | "gulp-exec": "^5.0.0", 48 | "gulp-jsdoc3": "^3.0.0", 49 | "gulp-newer": "^1.4.0", 50 | "gulp-nunjucks": "^5.1.0", 51 | "gulp-rename": "^2.0.0", 52 | "gulp-uglify": "^3.0.2", 53 | "jspot": "^0.3.17", 54 | "modclean": "^3.0.0-beta.1", 55 | "node-glob": "^1.2.0", 56 | "po2json": "github:mikeedwards/po2json#1.0.0-beta-3", 57 | "vinyl-buffer": "^1.0.1", 58 | "vinyl-source-stream": "^2.0.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /resources/ffmpeg/README.md: -------------------------------------------------------------------------------- 1 | 2 | If you want to package FFMPEG with Sozi, download, unzip and add 3 | [FFMPEG executables](https://ffbinaries.com/downloads) into these folders: 4 | 5 | * Linux 32-bit: `resources/ffmpeg/linux-ia32` 6 | * Linux 64-bit: `resources/ffmpeg/linux-x64` 7 | * Windows 32-bit: `resources/ffmpeg/win32-ia32` 8 | * Windows 64-bit: `resources/ffmpeg/win32-x64` 9 | * MacOS X 64-bit: `resources/ffmpeg/darwin-x64` 10 | -------------------------------------------------------------------------------- /resources/ffmpeg/darwin-x64/.gitignore: -------------------------------------------------------------------------------- 1 | ffmpeg 2 | -------------------------------------------------------------------------------- /resources/ffmpeg/linux-ia32/.gitignore: -------------------------------------------------------------------------------- 1 | ffmpeg 2 | -------------------------------------------------------------------------------- /resources/ffmpeg/linux-x64/.gitignore: -------------------------------------------------------------------------------- 1 | ffmpeg 2 | -------------------------------------------------------------------------------- /resources/ffmpeg/win32-ia32/.gitignore: -------------------------------------------------------------------------------- 1 | ffmpeg.exe 2 | -------------------------------------------------------------------------------- /resources/ffmpeg/win32-x64/.gitignore: -------------------------------------------------------------------------------- 1 | ffmpeg.exe 2 | -------------------------------------------------------------------------------- /resources/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sozi-projects/Sozi/67f2ce434dc8f23d17afc99e87503361f5c39d61/resources/icons/128x128.png -------------------------------------------------------------------------------- /resources/icons/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sozi-projects/Sozi/67f2ce434dc8f23d17afc99e87503361f5c39d61/resources/icons/16x16.png -------------------------------------------------------------------------------- /resources/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sozi-projects/Sozi/67f2ce434dc8f23d17afc99e87503361f5c39d61/resources/icons/256x256.png -------------------------------------------------------------------------------- /resources/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sozi-projects/Sozi/67f2ce434dc8f23d17afc99e87503361f5c39d61/resources/icons/32x32.png -------------------------------------------------------------------------------- /resources/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sozi-projects/Sozi/67f2ce434dc8f23d17afc99e87503361f5c39d61/resources/icons/64x64.png -------------------------------------------------------------------------------- /resources/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sozi-projects/Sozi/67f2ce434dc8f23d17afc99e87503361f5c39d61/resources/icons/favicon.ico -------------------------------------------------------------------------------- /resources/icons/sozi.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sozi-projects/Sozi/67f2ce434dc8f23d17afc99e87503361f5c39d61/resources/icons/sozi.icns -------------------------------------------------------------------------------- /resources/icons/sozi.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sozi-projects/Sozi/67f2ce434dc8f23d17afc99e87503361f5c39d61/resources/icons/sozi.ico -------------------------------------------------------------------------------- /resources/install/darwin/README.md: -------------------------------------------------------------------------------- 1 | No installation assets for this platform. 2 | -------------------------------------------------------------------------------- /resources/install/linux/install-local.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | CWD="$(dirname $0)/.." 3 | 4 | mkdir -p "$HOME"/bin 5 | ln -f -s "$CWD"/sozi "$HOME"/bin/sozi 6 | 7 | install -D -m644 "$CWD/install/sozi.png" "/$HOME/.local/share/icons/sozi.png" 8 | install -D -m755 "$CWD/install/sozi.desktop" "/$HOME/.local/share/applications/sozi.desktop" 9 | 10 | update-desktop-database 11 | -------------------------------------------------------------------------------- /resources/install/linux/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | CWD="$(dirname $0)/.." 3 | 4 | rm -rf /opt/sozi/ 5 | mkdir -p /opt/sozi/ 6 | cp -a "$CWD"/* /opt/sozi/ 7 | 8 | ln -f -s /opt/sozi/sozi /usr/bin/sozi 9 | 10 | install -D -m644 "$CWD/install/sozi.png" "/usr/share/pixmaps/sozi.png" 11 | install -D -m755 "$CWD/install/sozi.desktop" "/usr/share/applications/sozi.desktop" 12 | 13 | update-desktop-database 14 | -------------------------------------------------------------------------------- /resources/install/linux/sozi.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Sozi 3 | Comment=A zooming presentation editor and player 4 | Exec=sozi %f 5 | Icon=sozi.png 6 | Terminal=false 7 | Type=Application 8 | MimeType=image/svg+xml 9 | StartupNotify=false 10 | -------------------------------------------------------------------------------- /resources/install/linux/sozi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sozi-projects/Sozi/67f2ce434dc8f23d17afc99e87503361f5c39d61/resources/install/linux/sozi.png -------------------------------------------------------------------------------- /resources/install/win32/README.md: -------------------------------------------------------------------------------- 1 | No installation assets for this platform. 2 | -------------------------------------------------------------------------------- /src/css/sozi.editor.view.Preview.css: -------------------------------------------------------------------------------- 1 | #sozi-editor-view-preview { 2 | position: absolute; 3 | left: 10%; 4 | top: 10%; 5 | width: 80%; 6 | height: 80%; 7 | background: white; 8 | box-shadow: 0 0 20px 5px rgb(32, 32, 32); 9 | 10 | text-align: center; 11 | } 12 | 13 | #sozi-editor-view-preview svg { 14 | position: absolute; 15 | left: 0; 16 | top: 0; 17 | width: 100%; 18 | height: 100%; 19 | } 20 | 21 | #sozi-editor-view-preview svg a { 22 | cursor: default; 23 | } 24 | 25 | #sozi-editor-view-preview p, 26 | #sozi-editor-view-preview ul { 27 | margin: 2rem; 28 | padding: 0; 29 | } 30 | 31 | #sozi-editor-view-preview ul li { 32 | list-style-type: none; 33 | display: inline-block; 34 | margin: 0 1rem 0.5rem 1rem; 35 | } 36 | -------------------------------------------------------------------------------- /src/css/sozi.editor.view.Properties.css: -------------------------------------------------------------------------------- 1 | .properties { 2 | margin: 0.5rem 1rem; 3 | } 4 | 5 | .properties h1 { 6 | text-align: center; 7 | font-size: 120%; 8 | } 9 | 10 | .properties h2 { 11 | font-size: 110%; 12 | } 13 | 14 | .properties h3 { 15 | font-size: 100%; 16 | } 17 | 18 | .properties > div { 19 | margin-top: 0.5rem; 20 | margin-bottom: 0.3rem; 21 | } 22 | 23 | .properties > div.btn-group { 24 | text-align: center; 25 | } 26 | 27 | .properties .back { 28 | position: absolute; 29 | top: 0; 30 | left: 0; 31 | cursor: pointer; 32 | } 33 | 34 | .properties .side-by-side { 35 | display: flex; 36 | align-items: center; 37 | } 38 | 39 | .properties label { 40 | display: block; 41 | margin-top: 0.5rem; 42 | margin-bottom: 0.3rem; 43 | } 44 | 45 | .properties input, 46 | .properties select { 47 | width: 100%; 48 | } 49 | 50 | .properties span.btn-group, 51 | .properties :not(.btn-group):not(td) > button { 52 | margin-left: 1rem; 53 | } 54 | 55 | .properties { 56 | color: white; 57 | } 58 | 59 | .properties .help { 60 | margin-left: 1rem; 61 | } 62 | 63 | .properties table.custom-css-js { 64 | width: 100%; 65 | border-radius: 0.5rem; 66 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.5) inset; 67 | background-color: white; 68 | color: black; 69 | } 70 | 71 | .properties .custom-css-js tr th { 72 | width: 100%; 73 | } 74 | 75 | .properties .custom-css-js td:first-child { 76 | padding-left: 0.5rem; 77 | padding-right: 0.5rem; 78 | } 79 | 80 | .properties input.custom-css-js { 81 | display: none; 82 | } 83 | 84 | @keyframes spinner { 85 | to { transform: rotate(360deg); } 86 | } 87 | 88 | .spinner { 89 | margin-left: 1rem; 90 | margin-right: 1rem; 91 | } 92 | 93 | .spinner:before { 94 | content: ''; 95 | box-sizing: border-box; 96 | position: absolute; 97 | width: 1rem; 98 | height: 1rem; 99 | border-radius: 50%; 100 | border: 4px solid transparent; 101 | border-top-color: #333; 102 | animation: spinner .8s linear infinite; 103 | } 104 | -------------------------------------------------------------------------------- /src/css/sozi.editor.view.Timeline.css: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /* 6 | * Layout 7 | */ 8 | 9 | #sozi-editor-view-timeline { 10 | position: absolute; 11 | left: 0; 12 | bottom: 0; 13 | width: 100%; 14 | height: calc(100% - 2.5rem); 15 | overflow-y: auto; 16 | color: white; 17 | background: rgb(80, 80, 80); 18 | } 19 | 20 | #sozi-editor-view-timeline > div { 21 | position: absolute; 22 | top: 0; 23 | right: 0; 24 | width: 100%; 25 | height: 100%; 26 | overflow: hidden; 27 | } 28 | 29 | .timeline-top-left, 30 | .timeline-top-right, 31 | .timeline-bottom-left, 32 | .timeline-bottom-right { 33 | position: absolute; 34 | width: 50%; 35 | height: 50%; 36 | overflow: hidden; 37 | } 38 | 39 | .timeline-top-left, 40 | .timeline-top-right { 41 | top: 0; 42 | } 43 | 44 | .timeline-bottom-left, 45 | .timeline-bottom-right { 46 | bottom: 0; 47 | /* Always-visible horizontal scrollbar in left and right bottom tables. 48 | * Required for alignment between rows. */ 49 | overflow-x: scroll; 50 | } 51 | 52 | .timeline-top-left, 53 | .timeline-bottom-left { 54 | left: 0; 55 | } 56 | 57 | .timeline-top-right, 58 | .timeline-bottom-right { 59 | right: 0; 60 | } 61 | 62 | .timeline-top-right, 63 | .timeline-bottom-right { 64 | /* Always-visible vertical scrollbar in top and bottom right tables. 65 | * Required for alignment between columns. */ 66 | overflow-y: scroll; 67 | } 68 | 69 | .timeline { 70 | border-collapse: separate; 71 | border-spacing: 0 2px; 72 | } 73 | 74 | .timeline input, 75 | .timeline button, 76 | .timeline select { 77 | width: 100%; 78 | } 79 | 80 | .timeline td, 81 | .timeline th.frame-index, 82 | .timeline th.frame-title, 83 | .timeline th.layer-label { 84 | white-space: nowrap; 85 | overflow: hidden; 86 | text-overflow: ellipsis; 87 | width: 8rem; 88 | min-width: 3rem; 89 | max-width: 8rem; 90 | padding-left: 0.5rem; 91 | padding-right: 0.5rem; 92 | } 93 | 94 | .timeline th.layer-icons { 95 | width: auto; 96 | text-align: left; 97 | white-space: nowrap; 98 | } 99 | 100 | .timeline .layer-label { 101 | text-align: left; 102 | border-bottom-left-radius: 0.5rem; 103 | border-top-left-radius: 0.5rem; 104 | } 105 | 106 | .timeline .layer-icons .visibility, 107 | .timeline .layer-icons .remove { 108 | margin-right: 0.5rem; 109 | } 110 | 111 | .timeline .layer-label .remove { 112 | display: none; 113 | } 114 | 115 | .timeline .layer-label:hover .remove { 116 | display: inline; 117 | } 118 | 119 | .timeline .frame-title { 120 | border-top-left-radius: 0.5rem; 121 | border-top-right-radius: 0.5rem; 122 | } 123 | 124 | .timeline .frame-index .insert-before { 125 | float: left; 126 | position: relative; 127 | top: 0.5rem; 128 | } 129 | 130 | .timeline .frame-index .insert-after { 131 | float: right; 132 | position: relative; 133 | top: 0.5rem; 134 | } 135 | 136 | .timeline .frame-index i { 137 | display: none; 138 | } 139 | 140 | .timeline .frame-index:hover i { 141 | display: inline; 142 | } 143 | 144 | .timeline .collapse { 145 | visibility: collapse; 146 | } 147 | 148 | /* 149 | * Colors 150 | */ 151 | 152 | .timeline-bottom-right td { 153 | color: rgb(80, 80, 80); 154 | } 155 | 156 | .timeline td, 157 | .timeline th.frame-index, 158 | .timeline th.frame-title { 159 | border-left: 2px solid rgb(80, 80, 80); 160 | } 161 | 162 | .timeline .even { 163 | background: rgb(200, 200, 200); 164 | } 165 | 166 | .timeline .odd { 167 | background: rgb(230, 230, 230); 168 | } 169 | 170 | .timeline .link { 171 | border-left: none; 172 | } 173 | 174 | .timeline .selected { 175 | background: rgb(200, 220, 220); 176 | } 177 | 178 | .timeline th.selected { 179 | color: black; 180 | } 181 | 182 | .timeline .selected.current { 183 | background: rgb(150, 200, 200); 184 | } 185 | 186 | .timeline .frame-index.selected { 187 | color: rgb(200, 220, 220); 188 | background: transparent; 189 | } 190 | 191 | /* 192 | * Fonts 193 | */ 194 | 195 | .timeline td, 196 | .timeline th.frame-index, 197 | .timeline th.frame-title, 198 | .timeline th.layer-label { 199 | font-weight: normal; 200 | } 201 | 202 | .timeline .frame-index.selected { 203 | font-weight: bold; 204 | } 205 | 206 | /* 207 | * Behavior 208 | */ 209 | 210 | .timeline { 211 | -moz-user-select: none; 212 | -ms-user-select: none; 213 | -webkit-user-select: none; 214 | -o-user-select: none; 215 | user-select: none; 216 | } 217 | 218 | .timeline td, 219 | .timeline th.frame-index, 220 | .timeline th.frame-title, 221 | .timeline th.layer-label { 222 | cursor: default; 223 | } 224 | -------------------------------------------------------------------------------- /src/css/sozi.editor.view.Toolbar.css: -------------------------------------------------------------------------------- 1 | #sozi-editor-view-toolbar { 2 | position: absolute; 3 | left: 0; 4 | top: 0; 5 | width: 100%; 6 | height: 2.5rem; 7 | padding: 0.5rem 1rem; 8 | color: white; 9 | background: linear-gradient(to bottom, rgb(64, 64, 64), rgb(80, 80, 80) 2rem); 10 | } 11 | 12 | .aspect { 13 | width: 4rem; 14 | padding: 0.2rem 0.5rem; 15 | border: 1px solid rgb(80, 80, 80); 16 | } 17 | 18 | .group { 19 | margin-right: 1.5rem; 20 | } 21 | -------------------------------------------------------------------------------- /src/css/sozi.editor.view.css: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /* ------------------------------------------------------------------------- * 6 | * Layout 7 | * ------------------------------------------------------------------------- */ 8 | 9 | /* 10 | * Apply a natural box layout model to all elements: 11 | * http://www.paulirish.com/2012/box-sizing-border-box-ftw/ 12 | */ 13 | *, *:before, *:after { 14 | -moz-box-sizing: border-box; 15 | -webkit-box-sizing: border-box; 16 | box-sizing: border-box; 17 | } 18 | 19 | body { 20 | margin: 0; 21 | padding: 0; 22 | overflow: hidden; 23 | } 24 | 25 | .top { position: absolute; left: 0; top: 0; width: 100%; height: calc(70% - 2px); } 26 | .bottom { position: absolute; left: 0; bottom: 0; width: 100%; height: calc(30% - 2px); } 27 | .left { position: absolute; left: 0; top: 0; width: calc(70% - 2px); height: 100%; } 28 | .right { position: absolute; right: 0; top: 0; width: calc(30% - 2px); height: 100%; } 29 | 30 | .hsplitter { 31 | position: absolute; 32 | left: 0; 33 | top: calc(70% - 3px); 34 | width: 100%; 35 | height: 6px; 36 | } 37 | 38 | .vsplitter { 39 | position: absolute; 40 | left: calc(70% - 3px); 41 | top: 0; 42 | width: 6px; 43 | height: 100%; 44 | } 45 | 46 | .right { 47 | overflow-x: hidden; 48 | overflow-y: auto; 49 | } 50 | 51 | .btn-group { 52 | white-space: nowrap; 53 | } 54 | 55 | .message { 56 | position: fixed; 57 | width: 100%; 58 | top: 0; 59 | left: 0; 60 | padding: 0.5rem 1rem; 61 | } 62 | 63 | /* ------------------------------------------------------------------------- * 64 | * Colors and borders 65 | * ------------------------------------------------------------------------- */ 66 | 67 | body { 68 | background: rgb(80, 80, 80); 69 | } 70 | 71 | .top .left { 72 | background-image: linear-gradient(-45deg, 73 | rgb(80, 80, 80) 25%, rgb(64, 64, 64) 25%, rgb(64, 64, 64) 50%, 74 | rgb(80, 80, 80) 50%, rgb(80, 80, 80) 75%, rgb(64, 64, 64) 75%, rgb(64, 64, 64)); 75 | background-size: 1rem 1rem; 76 | } 77 | 78 | .right { 79 | background: rgb(64, 64, 64); 80 | } 81 | 82 | .hsplitter, .vsplitter { 83 | background: rgb(64, 64, 64); 84 | } 85 | 86 | .hsplitter:hover, .vsplitter:hover { 87 | background: rgb(80, 80, 80); 88 | } 89 | 90 | /* 91 | * Form elements 92 | */ 93 | 94 | input[type=text], 95 | input[type=number], 96 | section[contenteditable=true], 97 | select, 98 | button { 99 | border-radius: 0.5rem; 100 | border: none; 101 | padding: 0.3rem 0.5rem; 102 | color: black; 103 | } 104 | 105 | input[type=text], 106 | input[type=number], 107 | section[contenteditable=true] { 108 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.5) inset; 109 | background-color: white; 110 | } 111 | 112 | select, 113 | button { 114 | background: linear-gradient(to bottom, rgb(220, 220, 220), rgb(180, 180, 180) 100%); 115 | } 116 | 117 | select:active, 118 | button:active { 119 | background: linear-gradient(to bottom, rgb(200, 200, 200), rgb(160, 160, 160) 100%); 120 | } 121 | 122 | option { 123 | background: rgb(220, 220, 220); 124 | } 125 | 126 | 127 | input:disabled { 128 | background: rgb(220, 220, 220); 129 | } 130 | 131 | input:disabled, 132 | button:disabled { 133 | color: rgb(150, 150, 150); 134 | } 135 | 136 | .active { 137 | background: linear-gradient(to bottom, rgb(150, 200, 200), rgb(180, 220, 220) 100%); 138 | } 139 | 140 | button.active:active { 141 | background: linear-gradient(to bottom, rgb(135, 180, 180), rgb(160, 200, 200) 100%); 142 | } 143 | 144 | .multiple { 145 | background-image: linear-gradient(-45deg, 146 | rgb(150, 200, 200) 25%, rgb(220, 220, 220) 25%, rgb(220, 220, 220) 50%, 147 | rgb(150, 200, 200) 50%, rgb(150, 200, 200) 75%, rgb(220, 220, 220) 75%, rgb(220, 220, 220)); 148 | background-size: 1rem 1rem; 149 | } 150 | 151 | .btn-group button:first-child:not(:last-child) { 152 | border-top-left-radius: 0.5rem; 153 | border-bottom-left-radius: 0.5rem; 154 | border-top-right-radius: 0; 155 | border-bottom-right-radius: 0; 156 | border-right: 1px solid rgb(80, 80, 80); 157 | } 158 | 159 | .btn-group button:last-child:not(:first-child) { 160 | border-top-left-radius: 0; 161 | border-bottom-left-radius: 0; 162 | border-top-right-radius: 0.5rem; 163 | border-bottom-right-radius: 0.5rem; 164 | } 165 | 166 | .btn-group button:not(:first-child):not(:last-child) { 167 | border-radius: 0; 168 | border-right: 1px solid rgb(80, 80, 80); 169 | } 170 | 171 | .message.info { 172 | background-color: rgba(150, 200, 200, 0.8); 173 | } 174 | 175 | .message.error { 176 | background-color: rgba(255, 100, 100, 0.8); 177 | } 178 | 179 | /* ------------------------------------------------------------------------- * 180 | * Fonts 181 | * ------------------------------------------------------------------------- */ 182 | 183 | html { 184 | font-family: "InterVariable", sans-serif; 185 | } 186 | 187 | body { 188 | font-size: small; 189 | } 190 | 191 | .top-left p { 192 | font-size: 200%; 193 | text-align: center; 194 | } 195 | 196 | .message .title { 197 | font-weight: bold; 198 | } 199 | 200 | /* ------------------------------------------------------------------------- * 201 | * Actions and animations 202 | * ------------------------------------------------------------------------- */ 203 | 204 | body { 205 | user-select: none; 206 | } 207 | 208 | .message { 209 | opacity: 0; 210 | visibility: hidden; 211 | transition: visibility 0s 0.5s, opacity 0.5s ease; 212 | } 213 | 214 | .message.visible { 215 | opacity: 1; 216 | visibility: visible; 217 | transition: visibility 0s 0s, opacity 0.5s ease; 218 | } 219 | 220 | .hsplitter { 221 | cursor: ns-resize; 222 | } 223 | 224 | .vsplitter { 225 | cursor: ew-resize; 226 | } 227 | 228 | /* ------------------------------------------------------------------------- * 229 | * Fixes 230 | * ------------------------------------------------------------------------- */ 231 | 232 | input[type=range] { 233 | background: rgb(64, 64, 64); 234 | } 235 | 236 | input[type=range]::-moz-range-track { 237 | border-radius: 8px; 238 | height: 7px; 239 | border: 1px solid #bdc3c7; 240 | background-color: #fff; 241 | } 242 | 243 | input[type=range]::-moz-range-thumb { 244 | background: #ecf0f1; 245 | border: 1px solid #bdc3c7; 246 | width: 14px; 247 | height: 14px; 248 | border-radius: 10px; 249 | cursor: pointer; 250 | } 251 | -------------------------------------------------------------------------------- /src/index-browser.html: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | Sozi 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 |
26 |
27 |
    28 |
    29 |
    30 |
    31 |
    32 |
    33 |
    34 |
    35 |
    36 |
     
    37 |
    38 |
    39 |
    40 |
    41 |
    42 | 43 | 44 | -------------------------------------------------------------------------------- /src/index-electron.html: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | Sozi 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 23 | 24 | 25 |
    26 |
    27 |
    28 |
      29 |
      30 |
      31 |
      32 |
      33 |
      34 |
      35 |
      36 |
      37 |
       
      38 |
      39 |
      40 |
      41 |
      42 |
      43 | 44 | 45 | -------------------------------------------------------------------------------- /src/js/backend/AbstractBackend.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** @module */ 6 | 7 | /** The list of backends supported by the current editor. 8 | * 9 | * @type {module:backend/AbstractBackend.AbstractBackend[]} 10 | */ 11 | export const backendList = []; 12 | 13 | /** Add a backend to {@link module:backend/AbstractBackend.backendList|the list of supported backends}. 14 | * 15 | * @param {module:backend/AbstractBackend.AbstractBackend} backend - The backend to add. 16 | */ 17 | export function addBackend(backend) { 18 | backendList.push(backend); 19 | } 20 | 21 | /** Abstraction for the execution platform. */ 22 | export class AbstractBackend { 23 | /** Common constructor for backends. 24 | * 25 | * @param {module:Controller.Controller} controller - A controller instance. 26 | * @param {HTMLElement} container - The element that will contain the menu for choosing a backend. 27 | * @param {string} buttonId - The ID of the button to generate in the menu. 28 | * @param {string} buttonLabel - The text of the button to generate in the menu (already translated). 29 | */ 30 | constructor(controller, container, buttonId, buttonLabel) { 31 | /** The controller for this backend. 32 | * 33 | * @type {module:Controller.Controller} 34 | */ 35 | this.controller = controller; 36 | 37 | /** A list of files to save automatically. 38 | * 39 | * This is an array of file descriptors. The actual type of the 40 | * elements depends on the platform. 41 | * 42 | * @type {Array} 43 | */ 44 | this.autosavedFiles = []; 45 | 46 | container.innerHTML = ``; 47 | } 48 | 49 | /** Open a file chooser. 50 | * 51 | * This method opens a file dialog to open an SVG document. 52 | */ 53 | openFileChooser() { 54 | // Not implemented 55 | } 56 | 57 | /** Return the base name of a file. 58 | * 59 | * @param {any} fileDescriptor - A file descriptor (backend-dependent). 60 | * @returns {string} The file name. 61 | */ 62 | getName(fileDescriptor) { 63 | // Not implemented 64 | return ""; 65 | } 66 | 67 | /** Return the location of a file. 68 | * 69 | * @param {any} fileDescriptor - A file descriptor (backend-dependent). 70 | * @returns {string} The file location. 71 | */ 72 | getLocation(fileDescriptor) { 73 | // Not implemented 74 | return null; 75 | } 76 | 77 | /** Compare two file descriptors. 78 | * 79 | * @param {any} fd1 - A file descriptor (backend-dependent). 80 | * @param {any} fd2 - A file descriptor (backend-dependent). 81 | * @returns {boolean} - `true` if `fd1` and `fd2` represent the same file. 82 | */ 83 | sameFile(fd1, fd2) { 84 | return fd1 === fd2; 85 | } 86 | 87 | /** Find a file. 88 | * 89 | * @param {string} name - The base name of the file. 90 | * @param {any} location - The location of the file (backend-dependent). 91 | * @returns {Promise} - A promise that resolves to a file descriptor, rejected if not found. 92 | */ 93 | find(name, location) { 94 | return Promise.resolve("Not implemented"); 95 | } 96 | 97 | /** Load a file. 98 | * 99 | * This method loads a file and returns a promise with the content of this file. 100 | * 101 | * @param {any} fileDescriptor - A file to load (backend-dependent). 102 | * @returns {Promise} - A promise that resolves to the content of the file. 103 | */ 104 | load(fileDescriptor) { 105 | return Promise.resolve("Not implemented"); 106 | } 107 | 108 | /** Load a file synchronously. 109 | * 110 | * @param {any} fileDescriptor - A file to load (backend-dependent). 111 | * @returns {string} - The content of the file. 112 | */ 113 | loadSync(fileDescriptor) { 114 | // Not implemented 115 | return ""; 116 | } 117 | 118 | /** Create a new file. 119 | * 120 | * @param {string} name - The name of the file to create. 121 | * @param {any} location - The location of the file to create (backend-dependent). 122 | * @param {string} mimeType - The MIME type of the file to create. 123 | * @param {string} data - The content of the file to create. 124 | * @returns {Promise} - A promise that resolves to a file descriptor. 125 | */ 126 | create(name, location, mimeType, data) { 127 | return Promise.resolve("Not implemented"); 128 | } 129 | 130 | /** Save data to an existing file. 131 | * 132 | * @param {any} fileDescriptor - The file to save (backend-dependent). 133 | * @param {string} data - The new content of the file. 134 | * @returns {Promise} - A promise that resolves to the given file descriptor. 135 | */ 136 | save(fileDescriptor, data) { 137 | return Promise.resolve("Not implemented"); 138 | } 139 | 140 | /** Add the given file to the list of files to save automatically. 141 | * 142 | * @param {any} fileDescriptor - The file to autosave (backend-dependent). 143 | * @param {function():boolean} needsSaving - A function that returns `true` if the file needs saving. 144 | * @param {function():string} getData - A function that returns the data to save. 145 | */ 146 | autosave(fileDescriptor, needsSaving, getData) { 147 | this.autosavedFiles.push({fileDescriptor, needsSaving, getData}); 148 | } 149 | 150 | /** Check whether at least one file in the {@link module:backend/AbstractBackend.AbstractBackend#autosavedFiles|autosaved file list} needs saving. 151 | * 152 | * This is an accessor that uses the `needsSaving` function passed to {@link module:backend/AbstractBackend.AbstractBackend#autosave|autosave}. 153 | * Returns `true` if at least one file has unsaved modifications. 154 | * 155 | * @readonly 156 | * @type {boolean} 157 | */ 158 | get hasOutdatedFiles() { 159 | return this.autosavedFiles.some(file => file.needsSaving()); 160 | } 161 | 162 | /** Save all outdated files. 163 | * 164 | * This method calls {@link module:backend/AbstractBackend.AbstractBackend#save|save} for each file 165 | * in the {@link module:backend/AbstractBackend.AbstractBackend#autosavedFiles|autosaved file list} 166 | * where `needsSaving` returns `true`. 167 | * 168 | * It uses the function `getData` passed to {@link module:backend/AbstractBackend.AbstractBackend#autosave|autosave} 169 | * to write the new file content. 170 | * 171 | * @returns {Promise} - A promise that resolves when all autosaved files have been saved. 172 | */ 173 | saveOutdatedFiles() { 174 | return Promise.all( 175 | this.autosavedFiles 176 | .filter(file => file.needsSaving()) 177 | .map(file => this.save(file.fileDescriptor, file.getData())) 178 | ); 179 | } 180 | 181 | /** Save all files previously added to the {@link module:backend/AbstractBackend.AbstractBackend#autosavedFiles|autosaved file list}. 182 | * 183 | * Typically, we want to call this method each time the editor loses focus 184 | * and when the editor closes. 185 | * 186 | * @returns {Promise} - A promise that resolves when all autosaved files have been saved. 187 | */ 188 | doAutosave() { 189 | this.controller.preferences.save(); 190 | return this.saveOutdatedFiles(); 191 | } 192 | 193 | /** Show or hide the development tools of the current web browser. 194 | */ 195 | toggleDevTools() { 196 | // Not implemented 197 | } 198 | 199 | /** Get an application setting. 200 | * 201 | * This method is used in the {@linkcode module:view/Properties.Properties|Properties} view to assign getters 202 | * to HTML fields that represent preferences. 203 | * 204 | * @param {string} key - The name of the property to get. 205 | * @returns {any} - The value of the property in the application settings. 206 | */ 207 | getAppSetting(key) { 208 | // Not implemented 209 | } 210 | 211 | /** Set a property of the application settings. 212 | * 213 | * This method is used in the {@linkcode module:view/Properties.Properties|Properties} view to assign setters 214 | * to HTML fields that represent preferences. 215 | * 216 | * This operation cannot be undone. 217 | * 218 | * @param {string} key - The name of the property to set. 219 | * @param {any} newValue - The new value of the property. 220 | */ 221 | setAppSetting(key, newValue) { 222 | // Not implemented 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/js/backend/FileReader.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** @module */ 6 | 7 | import {AbstractBackend, addBackend} from "./AbstractBackend"; 8 | 9 | /** Browser FileReader backend. 10 | * 11 | * @extends module:backend/AbstractBackend.AbstractBackend 12 | */ 13 | export class FileReaderBackend extends AbstractBackend { 14 | 15 | /** Initialize a Sozi backend based on the FileReader API. 16 | * 17 | * @param {module:Controller.Controller} controller - A controller instance. 18 | * @param {HTMLElement} container - The element that will contain the menu for choosing a backend. 19 | */ 20 | constructor(controller, container) { 21 | const _ = controller.gettext; 22 | 23 | super(controller, container, "sozi-editor-backend-FileReader-input", _('Open an SVG file from your computer ( read-only)')); 24 | 25 | document.getElementById("sozi-editor-backend-FileReader-input").addEventListener("click", () => this.openFileChooser()); 26 | 27 | /** A hidden HTML file input element to open a file chooser. 28 | * 29 | * @type {HTMLInputElement} 30 | */ 31 | this.fileInput = document.createElement("input"); 32 | this.fileInput.style.display = "none"; 33 | this.fileInput.setAttribute("type", "file"); 34 | this.fileInput.setAttribute("accept", "image/svg+xml"); 35 | container.appendChild(this.fileInput); 36 | 37 | // Load the SVG document selected in the file input 38 | this.fileInput.addEventListener("change", evt => { 39 | if (evt.target.files.length) { 40 | this.controller.storage.setSVGFile(evt.target.files[0], this); 41 | } 42 | }); 43 | } 44 | 45 | /** @inheritdoc */ 46 | openFileChooser() { 47 | this.fileInput.dispatchEvent(new MouseEvent("click")); 48 | } 49 | 50 | /** @inheritdoc */ 51 | getName(fileDescriptor) { 52 | return fileDescriptor.name; 53 | } 54 | 55 | /** @inheritdoc */ 56 | sameFile(fd1, fd2) { 57 | return fd1.name === fd2.name; 58 | } 59 | 60 | /** @inheritdoc */ 61 | load(fileDescriptor) { 62 | const reader = new FileReader(); 63 | return new Promise((resolve, reject) => { 64 | reader.readAsText(fileDescriptor, "utf8"); 65 | reader.onload = () => { 66 | resolve(reader.result); 67 | }; 68 | reader.onerror = () => { 69 | reject(reader.error.name); 70 | }; 71 | }); 72 | } 73 | } 74 | 75 | addBackend(FileReaderBackend); 76 | -------------------------------------------------------------------------------- /src/js/backend/GoogleDrive.config.dist.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import {GoogleDrive} from "./GoogleDrive"; 6 | 7 | /* 8 | * Use the Google Developers Console to generate an 9 | * OAuth client ID for web application. 10 | * This key will be restricted to the domain where the 11 | * web application is hosted. 12 | */ 13 | GoogleDrive.clientId = "Your OAuth client Id"; 14 | 15 | /* 16 | * Use the Google Developers Console to generate a 17 | * developer key for browser applications. 18 | * This key will be restricted to the domain where the 19 | * web application is hosted. 20 | */ 21 | GoogleDrive.apiKey = "Your developer API key"; 22 | -------------------------------------------------------------------------------- /src/js/backend/GoogleDrive.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** @module */ 6 | 7 | /* global google, gapi */ 8 | 9 | import {AbstractBackend, addBackend} from "./AbstractBackend"; 10 | 11 | /** Google Drive backend. 12 | * 13 | * @extends module:backend/AbstractBackend.AbstractBackend 14 | */ 15 | export class GoogleDrive extends AbstractBackend { 16 | 17 | /** Initialize a Sozi backend based on the Google Drive API. 18 | * 19 | * @param {module:Controller.Controller} controller - A controller instance. 20 | * @param {HTMLElement} container - The element that will contain the menu for choosing a backend. 21 | */ 22 | constructor(controller, container) { 23 | const _ = controller.gettext; 24 | 25 | super(controller, container, "sozi-editor-backend-GoogleDrive-input", _("Open an SVG file from Google Drive")); 26 | 27 | this.clickToAuth = () => this.authorize(false); 28 | 29 | gapi.client.setApiKey(GoogleDrive.apiKey); 30 | this.authorize(true); 31 | } 32 | 33 | /** @inheritdoc */ 34 | openFileChooser() { 35 | this.picker.setVisible(true); 36 | } 37 | 38 | /** Authorize access to the Google Drive API. 39 | * 40 | * @private 41 | * @param {boolean} onInit - `true` if this is the first authorization request in this session. 42 | */ 43 | authorize(onInit) { 44 | gapi.auth.authorize({ 45 | client_id: GoogleDrive.clientId, 46 | scope: "https://www.googleapis.com/auth/drive", 47 | immediate: onInit 48 | }, authResult => this.onAuthResult(onInit, authResult)); 49 | } 50 | 51 | /** Process a Google Drive API authorization result. 52 | * 53 | * Called on completion of the authorization request. 54 | * 55 | * @private 56 | * @param {boolean} onInit - `true` if this is the first authorization request in this session. 57 | * @param {object} authResult - The authorization result. 58 | */ 59 | onAuthResult(onInit, authResult) { 60 | const inputButton = document.getElementById("sozi-editor-backend-GoogleDrive-input"); 61 | 62 | if (authResult && !authResult.error) { 63 | this.accessToken = authResult.access_token; 64 | // Access granted: create a file picker and show the "Load" button. 65 | gapi.client.load("drive", "v2"); 66 | gapi.load("picker", { 67 | callback: () => { 68 | this.createPicker(); 69 | inputButton.removeEventListener("click", this.clickToAuth); 70 | inputButton.addEventListener("click", () => this.openFileChooser()); 71 | if (!onInit) { 72 | this.openFileChooser(); 73 | } 74 | } 75 | }); 76 | } 77 | else { 78 | // No access token could be retrieved, show the button to start the authorization flow. 79 | inputButton.addEventListener("click", this.clickToAuth); 80 | } 81 | } 82 | 83 | /** Create a Google Drive file picker. 84 | * 85 | * @private 86 | */ 87 | createPicker() { 88 | const view = new google.picker.View(google.picker.ViewId.DOCS); 89 | view.setMimeTypes("image/svg+xml"); 90 | 91 | this.picker = new google.picker.PickerBuilder(). 92 | addView(view). 93 | setOAuthToken(this.accessToken). 94 | setCallback(data => { 95 | if (data[google.picker.Response.ACTION] === google.picker.Action.PICKED) { 96 | gapi.client.drive.files.get({fileId: data.docs[0].id}).execute(response => { 97 | if (!response.error) { 98 | this.controller.storage.setSVGFile(response, this); 99 | } 100 | else { 101 | console.log(response.error.message); 102 | } 103 | }); 104 | } 105 | }). 106 | build(); 107 | } 108 | 109 | /** @inheritdoc */ 110 | getName(fileDescriptor) { 111 | return fileDescriptor.title; 112 | } 113 | 114 | /** @inheritdoc */ 115 | getLocation(fileDescriptor) { 116 | return fileDescriptor.parents; 117 | } 118 | 119 | /** @inheritdoc */ 120 | sameFile(fd1, fd2) { 121 | return fd1.id === fd2.id; 122 | } 123 | 124 | /** @inheritdoc */ 125 | find(name, location) { 126 | return new Promise((resolve, reject) => { 127 | function findInParent(index) { 128 | gapi.client.drive.files.list({ 129 | q: "title = '" + name + "' and " + 130 | "'" + location[index].id + "' in parents" 131 | }).execute(response => { 132 | if (response.items && response.items.length) { 133 | resolve(response.items[0]); 134 | } 135 | else if (index < location.length - 1) { 136 | findInParent(index + 1); 137 | } 138 | else { 139 | reject("Not found"); 140 | } 141 | }); 142 | } 143 | findInParent(0); 144 | }); 145 | } 146 | 147 | /** @inheritdoc */ 148 | load(fileDescriptor) { 149 | // TODO implement the "change" event 150 | // The file is loaded using an AJAX GET operation. 151 | // The data type is forced to "text" to prevent parsing it. 152 | const xhr = new XMLHttpRequest(); 153 | xhr.open("GET", fileDescriptor.downloadUrl); 154 | xhr.setRequestHeader("Content-Type", fileDescriptor.mimeType); 155 | xhr.setRequestHeader("Authorization", "Bearer " + this.accessToken); 156 | return new Promise((resolve, reject) => { 157 | xhr.addEventListener("readystatechange", () => { 158 | if (xhr.readyState === 4) { 159 | if (xhr.status === 200) { 160 | resolve(xhr.responseText); 161 | } 162 | else { 163 | reject(xhr.status); 164 | } 165 | } 166 | }); 167 | xhr.send(); 168 | }); 169 | } 170 | 171 | /** @inheritdoc */ 172 | create(name, location, mimeType, data) { 173 | const boundary = "-------314159265358979323846"; 174 | const delimiter = "\r\n--" + boundary + "\r\n"; 175 | const closeDelimiter = "\r\n--" + boundary + "--"; 176 | 177 | const metadata = { 178 | title: name, 179 | parents: location, 180 | mimeType 181 | }; 182 | 183 | const multipartRequestBody = 184 | delimiter + 185 | "Content-Type: application/json\r\n\r\n" + JSON.stringify(metadata) + 186 | delimiter + 187 | "Content-Type: " + mimeType + "\r\n" + 188 | "Content-Transfer-Encoding: base64\r\n\r\n" + 189 | toBase64(data) + // Force UTF-8 encoding 190 | closeDelimiter; 191 | 192 | return new Promise((resolve, reject) => { 193 | gapi.client.request({ 194 | path: "/upload/drive/v2/files", 195 | method: "POST", 196 | params: { 197 | uploadType: "multipart" 198 | }, 199 | headers: { 200 | "Content-Type": 'multipart/mixed; boundary="' + boundary + '"' 201 | }, 202 | body: multipartRequestBody 203 | }).execute(response => { 204 | if (response.error) { 205 | reject(response.error.message); 206 | } 207 | else { 208 | resolve(response); 209 | } 210 | }); 211 | }); 212 | } 213 | 214 | /** @inheritdoc */ 215 | save(fileDescriptor, data) { 216 | const base64Data = toBase64(data); // Force UTF-8 encoding 217 | return new Promise((resolve, reject) => { 218 | gapi.client.request({ 219 | path: "/upload/drive/v2/files/" + fileDescriptor.id, 220 | method: "PUT", 221 | params: { 222 | uploadType: "media" 223 | }, 224 | headers: { 225 | "Content-Type": fileDescriptor.mimeType, 226 | "Content-Length": base64Data.length, 227 | "Content-Encoding": "base64" 228 | }, 229 | body: base64Data 230 | }).execute(response => { 231 | if (response.error) { 232 | reject(response.error.message); 233 | } 234 | else { 235 | this.controller.storage.onSave(fileDescriptor); 236 | resolve(fileDescriptor); 237 | } 238 | }); 239 | }); 240 | } 241 | } 242 | 243 | /** Encode data to base64. 244 | * 245 | * @private 246 | * @param {string} data - The data to encode. 247 | * @returns {string} The encoded data. 248 | */ 249 | function toBase64(data) { 250 | return btoa(unescape(encodeURIComponent(data))); 251 | } 252 | 253 | /** The Google Drive OAuth cliend Id. 254 | * 255 | * Override the value of this attribute in `GoogleDrive.config.js`. 256 | * 257 | * @static 258 | * @type {string} 259 | */ 260 | GoogleDrive.clientId = "Your OAuth client Id"; 261 | 262 | /** The Google Drive API key. 263 | * 264 | * Override the value of this attribute in `GoogleDrive.config.js`. 265 | * 266 | * @static 267 | * @type {string} 268 | */ 269 | GoogleDrive.apiKey = "Your developer API key"; 270 | 271 | addBackend(GoogleDrive); 272 | -------------------------------------------------------------------------------- /src/js/backend/index-browser.js: -------------------------------------------------------------------------------- 1 | 2 | import "./FileReader"; 3 | // import "./GoogleDrive.config"; 4 | -------------------------------------------------------------------------------- /src/js/backend/index-electron.js: -------------------------------------------------------------------------------- 1 | 2 | import "./Electron"; 3 | -------------------------------------------------------------------------------- /src/js/editor.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import "./backend"; 6 | import "./svg"; 7 | import {Presentation} from "./model/Presentation"; 8 | import {Selection} from "./model/Selection"; 9 | import {Preferences} from "./model/Preferences"; 10 | import {Viewport} from "./player/Viewport"; 11 | import {Player} from "./player/Player"; 12 | import {PlayerController} from "./player/PlayerController"; 13 | import {Controller} from "./Controller"; 14 | import {Preview} from "./view/Preview"; 15 | import {Properties} from "./view/Properties"; 16 | import {Toolbar} from "./view/Toolbar"; 17 | import {Timeline} from "./view/Timeline"; 18 | 19 | import nunjucks from "nunjucks"; 20 | 21 | window.addEventListener("load", () => { 22 | nunjucks.configure({watch: false}); 23 | 24 | const presentation = new Presentation(); 25 | const selection = new Selection(presentation); 26 | const viewport = new Viewport(presentation, true); 27 | const player = new Player(viewport, presentation); 28 | const playerController = new PlayerController(player); 29 | 30 | const preferences = new Preferences(); 31 | const controller = new Controller(preferences, presentation, selection, viewport, player); 32 | const preview = new Preview(document.getElementById("sozi-editor-view-preview"), presentation, selection, viewport, controller, playerController); 33 | const properties = new Properties(document.getElementById("sozi-editor-view-properties"), selection, controller); 34 | const toolbar = new Toolbar(document.getElementById("sozi-editor-view-toolbar"), properties, presentation, viewport, controller); 35 | const timeline = new Timeline(document.getElementById("sozi-editor-view-timeline"), presentation, selection, controller); 36 | controller.activate(); 37 | 38 | const body = document.querySelector("body"); 39 | const left = document.querySelector(".left"); 40 | const right = document.querySelector(".right"); 41 | const top = document.querySelector(".top"); 42 | const bottom = document.querySelector(".bottom"); 43 | const hsplitter = document.querySelector(".hsplitter"); 44 | const vsplitter = document.querySelector(".vsplitter"); 45 | 46 | document.getElementById("message").addEventListener("click", evt => { 47 | controller.hideNotification(); 48 | evt.stopPropagation(); 49 | }); 50 | 51 | let hsplitterStartY, vsplitterStartX; 52 | 53 | const hsplitterHeight = hsplitter.getBoundingClientRect().height; 54 | const vsplitterWidth = vsplitter.getBoundingClientRect().width; 55 | 56 | function hsplitterOnMouseMove(evt) { 57 | const topHeightPercent = 100 * (hsplitterStartY + evt.clientY) / window.innerHeight; 58 | top.style.height = topHeightPercent + "%"; 59 | hsplitter.style.top = topHeightPercent + "%"; 60 | bottom.style.height = `calc(${100 - topHeightPercent}% - ${hsplitterHeight}px)`; 61 | window.dispatchEvent(new UIEvent("resize")); 62 | return false; 63 | } 64 | 65 | function hsplitterOnMouseUp() { 66 | body.removeEventListener("mousemove", hsplitterOnMouseMove); 67 | body.removeEventListener("mouseup", hsplitterOnMouseUp); 68 | } 69 | 70 | function vsplitterOnMouseMove(evt) { 71 | const leftWidthPercent = 100 * (vsplitterStartX + evt.clientX) / window.innerWidth; 72 | left.style.width = leftWidthPercent + "%"; 73 | vsplitter.style.left = leftWidthPercent + "%"; 74 | right.style.width = `calc(${100 - leftWidthPercent}% - ${vsplitterWidth}px)`; 75 | window.dispatchEvent(new UIEvent("resize")); 76 | return false; 77 | } 78 | 79 | function vsplitterOnMouseUp() { 80 | body.removeEventListener("mousemove", vsplitterOnMouseMove); 81 | body.removeEventListener("mouseup", vsplitterOnMouseUp); 82 | } 83 | 84 | hsplitter.addEventListener("mousedown", evt => { 85 | hsplitterStartY = hsplitter.getBoundingClientRect().top - evt.clientY; 86 | body.addEventListener("mousemove", hsplitterOnMouseMove); 87 | body.addEventListener("mouseup", hsplitterOnMouseUp); 88 | }); 89 | 90 | vsplitter.addEventListener("mousedown", evt => { 91 | vsplitterStartX = vsplitter.getBoundingClientRect().left - evt.clientX; 92 | body.addEventListener("mousemove", vsplitterOnMouseMove); 93 | body.addEventListener("mouseup", vsplitterOnMouseUp); 94 | }); 95 | 96 | window.addEventListener("keydown", evt => { 97 | let key = ""; 98 | if (evt.ctrlKey) { 99 | key += "Ctrl+"; 100 | } 101 | if (evt.altKey) { 102 | key += "Alt+"; 103 | } 104 | if (evt.shiftKey) { 105 | key += "Shift+"; 106 | } 107 | key += evt.key.toUpperCase(); 108 | 109 | let actionFound = null; 110 | 111 | for (let action in preferences.keys) { 112 | if (preferences.keys[action] === key) { 113 | actionFound = action; 114 | break; 115 | } 116 | } 117 | 118 | // If an action was found, validate the active input field 119 | // before executing the action. 120 | const inputFocused = document.activeElement && /input|select|textarea|section/i.test(document.activeElement.tagName); 121 | 122 | if (actionFound && inputFocused) { 123 | document.activeElement.blur(); 124 | } 125 | 126 | switch (actionFound) { 127 | case "autoselectOutlineElement": 128 | controller.autoselectOutlineElement(); 129 | break; 130 | case "resetLayer": 131 | controller.resetLayer(); 132 | break; 133 | case "addFrame": 134 | controller.addFrame(); 135 | break; 136 | case "save": 137 | controller.save(); 138 | break; 139 | case "redo": 140 | controller.redo(); 141 | break; 142 | case "undo": 143 | controller.undo(); 144 | break; 145 | case "focusTitleField": 146 | document.getElementById('field-title').select(); 147 | break; 148 | case "reload": 149 | controller.reload(); 150 | break; 151 | case "toggleFullscreen": 152 | document.getElementById('btn-fullscreen').click(); 153 | break; 154 | case "toggleDevTools": 155 | controller.storage.backend.toggleDevTools(); 156 | break; 157 | } 158 | 159 | // Keyboard actions that may collide with input fields 160 | // are executed only if no input element has the focus. 161 | if (!actionFound && !inputFocused) { 162 | actionFound = true; 163 | switch (evt.key) { 164 | case "End": 165 | controller.selectFrame(-1); 166 | break; 167 | case "Home": 168 | controller.selectFrame(0); 169 | break; 170 | case "ArrowLeft": 171 | controller.selectRelativeFrame(-1); 172 | break; 173 | case "ArrowRight": 174 | controller.selectRelativeFrame(1); 175 | break; 176 | case "Delete": 177 | controller.deleteFrames(); 178 | break; 179 | case "a": 180 | if (evt.ctrlKey) { 181 | controller.selectAllFrames(); 182 | } 183 | else { 184 | actionFound = false; 185 | } 186 | break; 187 | default: 188 | actionFound = false; 189 | } 190 | } 191 | 192 | if (actionFound) { 193 | evt.preventDefault(); 194 | } 195 | }, false); 196 | }, false); 197 | -------------------------------------------------------------------------------- /src/js/exporter/exporter-preload.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /* global sozi */ 6 | 7 | import {ipcRenderer} from "electron"; 8 | 9 | ipcRenderer.on("initializeExporter", (evt, {callerId, frameIndex}) => { 10 | if (sozi.player.disableMedia) { 11 | sozi.player.disableMedia(); 12 | } 13 | 14 | document.querySelector(".sozi-blank-screen").style.display = "none"; 15 | 16 | sozi.player.jumpToFrame(frameIndex); 17 | ipcRenderer.sendTo(callerId, "jumpToFrame.done", frameIndex); 18 | }); 19 | 20 | ipcRenderer.on("jumpToFrame", (evt, {callerId, frameIndex}) => { 21 | sozi.player.jumpToFrame(frameIndex); 22 | ipcRenderer.sendTo(callerId, "jumpToFrame.done", frameIndex); 23 | }); 24 | 25 | function ipcMessage(name) { 26 | return new Promise(resolve => { 27 | ipcRenderer.once(name, resolve); 28 | }); 29 | } 30 | 31 | ipcRenderer.on("moveToNext", async (evt, {callerId, timeStepMs}) => { 32 | sozi.player.targetFrame = sozi.player.nextFrame; 33 | 34 | const layerProperties = sozi.player.targetFrame.layerProperties; 35 | const transitionDurationMs = sozi.player.targetFrame.transitionDurationMs; 36 | const targetFrameIndex = sozi.player.targetFrame.index; 37 | 38 | for (let camera of sozi.viewport.cameras) { 39 | const lp = layerProperties[camera.layer.index]; 40 | sozi.player.setupTransition(camera, lp.transitionTimingFunction, lp.transitionRelativeZoom, lp.transitionPath); 41 | } 42 | 43 | for (let timeMs = 0; timeMs < transitionDurationMs; timeMs += timeStepMs) { 44 | sozi.player.onAnimatorStep(timeMs / transitionDurationMs); 45 | ipcRenderer.sendTo(callerId, "moveToNext.step"); 46 | await ipcMessage("moveToNext.more"); 47 | } 48 | 49 | sozi.player.jumpToFrame(targetFrameIndex); 50 | ipcRenderer.sendTo(callerId, "jumpToFrame.done", targetFrameIndex); 51 | }); 52 | -------------------------------------------------------------------------------- /src/js/exporter/index-browser.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** @module */ 6 | 7 | /** Export a presentation to a PDF document. 8 | * 9 | * @param {module:model/Presentation.Presentation} presentation - The presentation to export. 10 | * @param {string} htmlFileName - The name of the presentation HTML file. 11 | * @returns {Promise} - A promise that resolves when the operation completes. 12 | */ 13 | export function exportToPDF(presentation, htmlFileName) { 14 | console.log("Export to PDF not supported on this platform."); 15 | return Promise.resolve(); 16 | } 17 | 18 | /** Export a presentation to a PPTX document. 19 | * 20 | * @param {module:model/Presentation.Presentation} presentation - The presentation to export. 21 | * @param {string} htmlFileName - The name of the presentation HTML file. 22 | * @returns {Promise} - A promise that resolves when the operation completes. 23 | */ 24 | export function exportToPPTX(presentation, htmlFileName) { 25 | console.log("Export to PPTX not supported on this platform."); 26 | return Promise.resolve(); 27 | } 28 | 29 | /** Export a presentation to a video document. 30 | * 31 | * @param {module:model/Presentation.Presentation} presentation - The presentation to export. 32 | * @param {string} htmlFileName - The name of the presentation HTML file. 33 | * @returns {Promise} - A promise that resolves when the operation completes. 34 | */ 35 | export function exportToVideo(presentation, htmlFileName) { 36 | console.log("Export to video not supported on this platform."); 37 | return Promise.resolve(); 38 | } 39 | -------------------------------------------------------------------------------- /src/js/i18n.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** Internationalization support in the presentation editor. 6 | * 7 | * @module 8 | */ 9 | 10 | import Jed from "jed"; 11 | import locales from "./locales"; 12 | 13 | /** Convert a language tag to a dash-separated lowercase string. 14 | * 15 | * @param {string} tag - A language tag. 16 | * @returns {string} - The same language tag, dash-separated lowercase. 17 | */ 18 | function normalize(tag) { 19 | return tag.replace(/_/g, "-").toLowerCase(); 20 | } 21 | 22 | /** Initialize the internationalization support in the current editor. 23 | * 24 | * In autodection mode, this function will query the web browser for the 25 | * current language. 26 | * 27 | * @param {string} lang - A language tag to use, or `"auto"`. 28 | * @returns {Jed} - An instance of `Jed` that provides the `gettext` function. 29 | */ 30 | export function init(lang="auto") { 31 | if (lang === "auto") { 32 | lang = window.navigator.languages && window.navigator.languages.length ? 33 | window.navigator.languages[0] : 34 | (window.navigator.language || window.navigator.userLanguage || "en"); 35 | } 36 | 37 | // Normalize the given language tag and extract the language code alone 38 | lang = normalize(lang); 39 | const langShort = lang.split("-")[0]; 40 | 41 | // Find the user language in the available locales 42 | const allLanguages = Object.keys(locales).map(normalize); 43 | let langIndex = allLanguages.indexOf(lang); 44 | if (langIndex < 0) { 45 | langIndex = allLanguages.indexOf(langShort); 46 | } 47 | 48 | const localeData = langIndex >= 0 ? locales[Object.keys(locales)[langIndex]] : {}; 49 | 50 | return new Jed(localeData); 51 | } 52 | -------------------------------------------------------------------------------- /src/js/index-electron.js: -------------------------------------------------------------------------------- 1 | 2 | import {app, BrowserWindow} from "electron"; 3 | import * as remoteMain from "@electron/remote/main"; 4 | import settings from "electron-app-settings"; 5 | 6 | remoteMain.initialize(); 7 | 8 | // Keep a global reference of the window object, if you don't, the window will 9 | // be closed automatically when the JavaScript object is garbage collected. 10 | let mainWindow; 11 | 12 | function createWindow () { 13 | // This sets the initial window size. 14 | // If Sozi has been opened before, the size and location will be 15 | // loaded from local storage in backend/Electron.js. 16 | mainWindow = new BrowserWindow({ 17 | width: 800, 18 | height: 600, 19 | webPreferences: { 20 | nodeIntegration: true, 21 | contextIsolation: false, 22 | sandbox: false, 23 | spellcheck: false 24 | } 25 | }); 26 | 27 | remoteMain.enable(mainWindow.webContents); 28 | 29 | mainWindow.setMenuBarVisibility(false); 30 | 31 | if (process.env.SOZI_DEVTOOLS) { 32 | mainWindow.webContents.openDevTools(); 33 | } 34 | 35 | mainWindow.loadURL(`file://${__dirname}/../index.html`); 36 | 37 | mainWindow.on("leave-html-full-screen", () => { 38 | mainWindow.setMenuBarVisibility(false); 39 | }); 40 | 41 | // Emitted when the window is closed. 42 | mainWindow.on("closed", () => { 43 | // Dereference the window object, usually you would store windows 44 | // in an array if your app supports multi windows, this is the time 45 | // when you should delete the corresponding element. 46 | mainWindow = null; 47 | }); 48 | } 49 | 50 | // Workaround for launching error "GPU process isn't usable. Goodbye." 51 | // See issue https://github.com/sozi-projects/Sozi/issues/603 52 | app.commandLine.appendSwitch("disable-gpu-sandbox"); 53 | 54 | // Color correct rendering (on by default). 55 | if (!settings.has("enableColorCorrectRendering")) { 56 | settings.set("enableColorCorrectRendering", true); 57 | } 58 | 59 | if (!settings.get("enableColorCorrectRendering")) { 60 | app.commandLine.appendSwitch("disable-color-correct-rendering"); 61 | } 62 | 63 | // Hardware acceleration (on by default). 64 | if (!settings.has("enableHardwareAcceleration")) { 65 | settings.set("enableHardwareAcceleration", true); 66 | } 67 | 68 | if (!settings.get("enableHardwareAcceleration")) { 69 | app.disableHardwareAcceleration(); 70 | } 71 | 72 | // This method will be called when Electron has finished 73 | // initialization and is ready to create browser windows. 74 | // Some APIs can only be used after this event occurs. 75 | app.on("ready", createWindow); 76 | 77 | // Quit when all windows are closed. 78 | app.on("window-all-closed", () => { 79 | // On OS X it is common for applications and their menu bar 80 | // to stay active until the user quits explicitly with Cmd + Q 81 | if (process.platform !== "darwin") { 82 | app.quit(); 83 | } 84 | }); 85 | 86 | app.on("activate", () => { 87 | // On OS X it's common to re-create a window in the app when the 88 | // dock icon is clicked and there are no other windows open. 89 | if (mainWindow === null) { 90 | createWindow(); 91 | } 92 | }); 93 | -------------------------------------------------------------------------------- /src/js/model/Preferences.js: -------------------------------------------------------------------------------- 1 | 2 | /* This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 5 | 6 | /** @module */ 7 | 8 | /** Sozi editor preferences. */ 9 | export class Preferences { 10 | /** Initialize a preferences object with default values. */ 11 | constructor() { 12 | /** The preferred language of the user interface. 13 | * 14 | * The value of this property should be a [BCP 47 language tag](https://tools.ietf.org/rfc/bcp/bcp47.txt) 15 | * with an [ISO 639-1 language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes), 16 | * or `"auto"` to let the editor use the system default language. 17 | * 18 | * @default 19 | * @type {string} 20 | */ 21 | this.language = "auto"; 22 | 23 | /** The preferred font size, in points (pt). 24 | * 25 | * @default 26 | * @type {number} 27 | */ 28 | this.fontSize = 11; 29 | 30 | /** Enable notifications on file save and reload. 31 | * 32 | * @default 33 | * @type {boolean} 34 | */ 35 | this.enableNotifications = true; 36 | 37 | /** Animate the transitions when moving from one frame to another in the editor. 38 | * 39 | * @default 40 | * @type {boolean} 41 | */ 42 | this.animateTransitions = true; 43 | 44 | /** When to save an edited presentation. 45 | * 46 | * Supported values are: 47 | * - `"onblur"`: save when the editor window loses focus. 48 | * - `"manual"`: save only on user request. 49 | * 50 | * @default 51 | * @type {string} 52 | */ 53 | this.saveMode = "onblur"; 54 | 55 | /** When to reload an externally modified SVG document. 56 | * 57 | * Supported values are: 58 | * - `"auto"`: reload immediately. 59 | * - `"onfocus"`: reload when the editor window gains the focus. 60 | * - `"manual"`: reload only on user request. 61 | * 62 | * @default 63 | * @type {string} 64 | */ 65 | this.reloadMode = "auto"; 66 | 67 | /** The supported keyboard shortcuts. 68 | * 69 | * A keyboard shortcut is represented as a [key identifier](https://developer.mozilla.org/fr/docs/Web/API/KeyboardEvent/key/Key_Values), 70 | * optionally preceded by one or more modifiers (`"Ctrl+"`, `"Alt+"`, `"Shift+"`). 71 | * 72 | * Examples: 73 | * - `"A"`: the letter "A" 74 | * - `"ArrowLeft"`: the left arrow key. 75 | * - `"Ctrl+Shift+ArrowLeft"`: Ctrl, Shift, and the left arrow key simultaneously. 76 | * 77 | * @type {object} 78 | * @property {string} autoselectOutlineElement - Detect and select the outline element in the current frame and selected layers 79 | * @property {string} resetLayer - Reset the selected layers to their default geometry 80 | * @property {string} addFrame - Create a new frame 81 | * @property {string} save - Save the presentation 82 | * @property {string} redo - Execute the latest undone action 83 | * @property {string} undo - Undo the latest action 84 | * @property {string} focusTitleField - Give the focus to the "Frame title" field in the properties pane 85 | * @property {string} reload - Reload the SVG document 86 | * @property {string} toggleFullscreen - Enter or exit fullscreen mode 87 | * @property {string} toggleDevTools - Open or close the developer tools 88 | */ 89 | this.keys = { 90 | autoselectOutlineElement: "Ctrl+E", 91 | resetLayer : "Ctrl+R", 92 | addFrame : "Ctrl+N", 93 | save : "Ctrl+S", 94 | redo : "Ctrl+Y", 95 | undo : "Ctrl+Z", 96 | focusTitleField : "F2", 97 | reload : "F5", 98 | toggleFullscreen : "F11", 99 | toggleDevTools : "F12" 100 | }; 101 | } 102 | 103 | /** Save the preferences to local storage */ 104 | save() { 105 | for (let key of Object.keys(this)) { 106 | localStorage.setItem(key, JSON.stringify(this[key])); 107 | } 108 | } 109 | 110 | /** Load the preferences from local storage */ 111 | load() { 112 | for (let key of Object.keys(this)) { 113 | const value = localStorage.getItem(key); 114 | if (value === null) { 115 | return; 116 | } 117 | const pref = JSON.parse(value); 118 | if (typeof pref === "object") { 119 | for (let [fieldName, fieldValue] of Object.entries(pref)) { 120 | this[key][fieldName] = fieldValue; 121 | } 122 | } 123 | else { 124 | this[key] = JSON.parse(value); 125 | } 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/js/model/Selection.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** @module */ 6 | 7 | /** Selection in the timeline of the Sozi editor. 8 | * 9 | * A Selection instance holds the currently selected 10 | * frames and layers of the presentation. 11 | */ 12 | export class Selection { 13 | 14 | /** Create an empty selection for a given presentation. 15 | * 16 | * @param {module:model/Presentation.Presentation} presentation - A Sozi presentation object 17 | */ 18 | constructor(presentation) { 19 | /** The presentation where selections happen. 20 | * 21 | * @type {module:model/Presentation.Presentation} 22 | */ 23 | this.presentation = presentation; 24 | 25 | /** The list of selected frames. 26 | * 27 | * @default 28 | * @type {module:model/Presentation.Frame[]} 29 | */ 30 | this.selectedFrames = []; 31 | 32 | /** The list of selected layers. 33 | * 34 | * @default 35 | * @type {module:model/Presentation.Layer[]} 36 | */ 37 | this.selectedLayers = []; 38 | } 39 | 40 | /** Convert this instance to a plain object that can be stored as JSON. 41 | * 42 | * The result contains all the properties needed by the editor to restore 43 | * the state of this instance. 44 | * 45 | * @returns {object} - A plain object with the properties needed by the editor. 46 | */ 47 | toStorable() { 48 | return { 49 | selectedFrames: this.selectedFrames.map(frame => frame.frameId), 50 | selectedLayers: this.selectedLayers.map(layer => layer.groupId) 51 | }; 52 | } 53 | 54 | /** Copy the properties of the given object into this instance. 55 | * 56 | * @param {object} storable - A plain object with the properties to copy. 57 | */ 58 | fromStorable(storable) { 59 | if ("selectedFrames" in storable) { 60 | this.selectedFrames = []; 61 | for (let frameId of storable.selectedFrames) { 62 | const frame = this.presentation.getFrameWithId(frameId); 63 | if (frame) { 64 | this.selectedFrames.push(frame); 65 | } 66 | } 67 | } 68 | 69 | if ("selectedLayers" in storable) { 70 | this.selectedLayers = []; 71 | for (let groupId of storable.selectedLayers) { 72 | const layer = this.presentation.getLayerWithId(groupId); 73 | if (layer) { 74 | this.selectedLayers.push(layer); 75 | } 76 | } 77 | } 78 | } 79 | 80 | /** The frame that was selected last, `null` if no frame is selected. 81 | * 82 | * @type {module:model/Presentation.Frame} 83 | */ 84 | get currentFrame() { 85 | return this.selectedFrames.length ? 86 | this.selectedFrames[this.selectedFrames.length - 1] : 87 | null; 88 | } 89 | 90 | /** Check whether this selection contains the given frames. 91 | * 92 | * @param {module:model/Presentation.Frame[]} frames - The frames to check. 93 | * @returns {boolean} - `true` if all the given frames are selected. 94 | */ 95 | hasFrames(frames) { 96 | return frames.every(frame => this.selectedFrames.indexOf(frame) >= 0); 97 | } 98 | 99 | /** Add a frame to this selection. 100 | * 101 | * @param {module:model/Presentation.Frame} frame - The frame to add. 102 | */ 103 | addFrame(frame) { 104 | if (this.selectedFrames.indexOf(frame) < 0) { 105 | this.selectedFrames.push(frame); 106 | } 107 | } 108 | 109 | /** Remove a frame from this selection. 110 | * 111 | * @param {module:model/Presentation.Frame} frame - The frame to remove. 112 | */ 113 | removeFrame(frame) { 114 | const index = this.selectedFrames.indexOf(frame); 115 | if (index >= 0) { 116 | this.selectedFrames.splice(index, 1); 117 | } 118 | } 119 | 120 | /** Add or remove the given frame to/from this selection. 121 | * 122 | * If the frame is not selected, add it to the selection, 123 | * otherwise, remove it. 124 | * 125 | * @param {module:model/Presentation.Frame} frame - The frame to add or remove. 126 | */ 127 | toggleFrameSelection(frame) { 128 | const index = this.selectedFrames.indexOf(frame); 129 | if (index >= 0) { 130 | this.selectedFrames.splice(index, 1); 131 | } 132 | else { 133 | this.selectedFrames.push(frame); 134 | } 135 | } 136 | 137 | /** Check whether this selection contains the given layers. 138 | * 139 | * @param {module:model/Presentation.Layer[]} layers - The layers to check. 140 | * @returns {boolean} - `true` if all the given layers are selected. 141 | */ 142 | hasLayers(layers) { 143 | return layers.every(layer => this.selectedLayers.indexOf(layer) >= 0); 144 | } 145 | 146 | /** Add a layer to this selection. 147 | * 148 | * @param {module:model/Presentation.Layer} layer - The layer to add. 149 | */ 150 | addLayer(layer) { 151 | if (this.selectedLayers.indexOf(layer) < 0) { 152 | this.selectedLayers.push(layer); 153 | } 154 | } 155 | 156 | /** Remove a layer from this selection. 157 | * 158 | * @param {module:model/Presentation.Layer} layer - The layer to remove. 159 | */ 160 | removeLayer(layer) { 161 | const index = this.selectedLayers.indexOf(layer); 162 | if (index >= 0) { 163 | this.selectedLayers.splice(index, 1); 164 | } 165 | } 166 | 167 | /** Add or remove the given layer to/from this selection. 168 | * 169 | * If the layer is not selected, add it to the selection, 170 | * otherwise, remove it. 171 | * 172 | * @param {module:model/Presentation.Layer} layer - The layer to add or remove. 173 | */ 174 | toggleLayerSelection(layer) { 175 | const index = this.selectedLayers.indexOf(layer); 176 | if (index >= 0) { 177 | this.selectedLayers.splice(index, 1); 178 | } 179 | else { 180 | this.selectedLayers.push(layer); 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/js/player.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /* global sozi */ 6 | 7 | import {SVGDocumentWrapper} from "./svg/SVGDocumentWrapper"; 8 | import {Presentation, Frame} from "./model/Presentation"; 9 | import {Viewport} from "./player/Viewport"; 10 | import {Player} from "./player/Player"; 11 | import {PlayerController} from "./player/PlayerController"; 12 | import * as Media from "./player/Media"; 13 | import * as FrameList from "./player/FrameList"; 14 | import * as FrameNumber from "./player/FrameNumber"; 15 | import * as FrameURL from "./player/FrameURL"; 16 | import * as TouchGestures from "./player/TouchGestures"; 17 | 18 | /** Identifies the current player to be in presenter mode. 19 | * 20 | * In presenter mode, message events are forwarded to the player 21 | * rather than to the player's controller. 22 | * 23 | * @default 24 | */ 25 | let isPresenterMode = false; 26 | 27 | /** Put the current player in presenter mode. 28 | * 29 | * This function is used by the presenter console that uses several 30 | * players as *previews* of the previous, current and next frames. 31 | * 32 | * In presenter mode, keyboard and mouse navigation are disabled, 33 | * frame numbers are hidden, hyperlink in the previous and next views are 34 | * disabled, and in the current view, hyperlink clicks are forwarded to the 35 | * main presentation window. 36 | * 37 | * @param {Window} mainWindow - The browser window that plays the main presentation. 38 | * @param {boolean} isCurrent - Is the current player showing the current frame? 39 | */ 40 | function setPresenterMode(mainWindow, isCurrent) { 41 | isPresenterMode = true; 42 | sozi.player.disableMedia(); 43 | sozi.player.pause(); 44 | 45 | sozi.presentation.enableMouseTranslation = 46 | sozi.presentation.enableMouseNavigation = 47 | sozi.presentation.enableKeyboardZoom = 48 | sozi.presentation.enableKeyboardRotation = 49 | sozi.presentation.enableKeyboardNavigation = false; 50 | 51 | for (let frame of sozi.presentation.frames) { 52 | frame.showFrameNumber = false; 53 | } 54 | 55 | if (isCurrent) { 56 | // Forward hyperlink clicks to the main presentation window. 57 | for (let link of sozi.presentation.document.root.getElementsByTagName("a")) { 58 | link.addEventListener("click", evt => { 59 | if (link.id) { 60 | mainWindow.postMessage({name: "click", id: link.id}, "*"); 61 | } 62 | evt.preventDefault(); 63 | }, false); 64 | } 65 | } 66 | else { 67 | sozi.presentation.document.disableHyperlinks(true); 68 | } 69 | } 70 | 71 | /** Process a frame change event. 72 | * 73 | * This event handler is setup when the current player is connected to 74 | * a presenter console. 75 | * On frame change, the event is forwarded to the presenter. 76 | * 77 | * @param {Window} presenterWindow - The window that shows the presenter console. 78 | * 79 | * @listens module:player/Player.frameChange 80 | */ 81 | function onFrameChange(presenterWindow) { 82 | presenterWindow.postMessage({ 83 | name : "frameChange", 84 | index: sozi.player.currentFrame.index, 85 | title: sozi.player.currentFrame.title, 86 | notes: sozi.player.currentFrame.notes 87 | }, "*"); 88 | } 89 | 90 | /** Setup an event handler to forward the frame change event to the presenter window. 91 | * 92 | * This function is called when initializing the connexion between the current 93 | * presentation and a presenter console in another window. 94 | * 95 | * @param {Window} presenterWindow - The window that shows the presenter console. 96 | */ 97 | function notifyOnFrameChange(presenterWindow) { 98 | sozi.player.on("frameChange", () => onFrameChange(presenterWindow)); 99 | 100 | // Send the message to set the initial frame data in the presenter window. 101 | onFrameChange(presenterWindow); 102 | } 103 | 104 | // Process messages from a presenter console. 105 | window.addEventListener("message", evt => { 106 | switch (evt.data.name) { 107 | case "loaded": 108 | // This message is received if the presentation is played directly 109 | // in a browser window. Ignore it. 110 | break; 111 | case "notifyOnFrameChange": 112 | // Install an event handler to forward the frame change event 113 | // to the presenter console. 114 | notifyOnFrameChange(evt.source); 115 | break; 116 | case "setPresenterMode": 117 | // Set this presentation into presenter mode. 118 | setPresenterMode(evt.source, evt.data.isCurrent); 119 | break; 120 | case "click": { 121 | // Forward the click event on a hyperlink in the presenter console 122 | // to the same hyperlink in the main presentation. 123 | const link = sozi.presentation.document.root.getElementById(evt.data.id); 124 | // We use dispatchEvent here because 125 | // SVG elements do not have a click method. 126 | link.dispatchEvent(new MouseEvent("click")); 127 | break; 128 | } 129 | default: { 130 | // Interpret a message as a method call to the current Sozi player. 131 | // The message must be of the form: {name: string, args: any[]}. 132 | const receiver = isPresenterMode ? sozi.player : sozi.playerController; 133 | const method = receiver[evt.data.name]; 134 | const args = evt.data.args || []; 135 | if (typeof method === "function") { 136 | method.apply(receiver, args); 137 | } 138 | else { 139 | console.log(`Unsupported message: ${evt.data.name}`); 140 | } 141 | } 142 | } 143 | }); 144 | 145 | // Initialize the Sozi player when the document is loaded. 146 | window.addEventListener("load", () => { 147 | const svgRoot = document.querySelector("svg"); 148 | svgRoot.style.display = "initial"; 149 | 150 | const presentation = new Presentation(); 151 | presentation.setSVGDocument(new SVGDocumentWrapper(svgRoot)); 152 | 153 | const viewport = new Viewport(presentation); 154 | viewport.onLoad(); 155 | presentation.setInitialCameraState(); 156 | 157 | presentation.fromStorable(window.soziPresentationData); 158 | if (!presentation.frames.length) { 159 | const frame = new Frame(presentation); 160 | frame.setAtStates(viewport.cameras); 161 | presentation.frames.push(frame); 162 | } 163 | 164 | const player = new Player(viewport, presentation); 165 | const playerController = new PlayerController(player); 166 | playerController.onLoad(); 167 | 168 | Media.init(player); 169 | FrameList.init(player, playerController); 170 | FrameNumber.init(player); 171 | FrameURL.init(player); 172 | TouchGestures.init(player, presentation, playerController); 173 | 174 | window.sozi = { 175 | get presentation() { 176 | return presentation; 177 | }, 178 | get viewport() { 179 | return viewport; 180 | }, 181 | get player() { 182 | return player; 183 | }, 184 | get playerController() { 185 | return playerController 186 | } 187 | }; 188 | 189 | player.on("stateChange", () => { 190 | if (player.playing) { 191 | document.title = presentation.title; 192 | } 193 | else { 194 | document.title = presentation.title + " (Paused)"; 195 | } 196 | }); 197 | 198 | window.addEventListener("resize", () => viewport.repaint()); 199 | 200 | player.playFromFrame(FrameURL.getFrame()); 201 | 202 | viewport.repaint(); 203 | player.disableBlankScreen(); 204 | 205 | document.querySelector(".sozi-blank-screen .spinner").style.display = "none"; 206 | }); 207 | 208 | /** Identifies the window that opened this presentation. 209 | * 210 | * This constant can typically have three possible values: 211 | * - a presenter console window that opened this window to display the main presentation, 212 | * - a parent window if this presentation is opened in a frame, 213 | * - the current window. 214 | * 215 | * @readonly 216 | * @type {Window} 217 | */ 218 | const opener = window.opener || window.parent; 219 | 220 | /** Check that Sozi is loaded and notify a presenter console. 221 | * 222 | * This function will repeatedly check whether the `window.sozi` variable is populated. 223 | * On success, it will send the `loaded` message to the presenter console. 224 | */ 225 | function checkSozi() { 226 | if (window.sozi) { 227 | opener.postMessage({ 228 | name: "loaded", 229 | length: sozi.presentation.frames.length, 230 | }, "*"); 231 | } 232 | else { 233 | setTimeout(checkSozi, 1); 234 | } 235 | } 236 | 237 | if (opener) { 238 | checkSozi(); 239 | } 240 | -------------------------------------------------------------------------------- /src/js/player/Animator.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** @module */ 6 | 7 | import {EventEmitter} from "events"; 8 | 9 | /** The browser-specific function to request an animation frame. 10 | * 11 | * @readonly 12 | * @type {Function} 13 | */ 14 | const doRequestAnimationFrame = 15 | window.requestAnimationFrame || 16 | window.mozRequestAnimationFrame || 17 | window.webkitRequestAnimationFrame || 18 | window.msRequestAnimationFrame || 19 | window.oRequestAnimationFrame; 20 | 21 | /** The object that provides the `now` method to read the current time. 22 | * 23 | * @readonly 24 | * @type {Function} 25 | */ 26 | const perf = window.performance && window.performance.now ? window.performance : Date; 27 | 28 | /** The default time step. 29 | * 30 | * For browsers that do not support animation frames. 31 | * 32 | * @default 33 | * @type {number} 34 | */ 35 | const TIME_STEP_MS = 40; 36 | 37 | /** The handle provided by `setInterval`. 38 | * 39 | * For browsers that do not support animation frames. 40 | */ 41 | let timer; 42 | 43 | /** The number of running animators. 44 | * 45 | * @default 46 | * @type {number} 47 | */ 48 | let runningAnimators = 0; 49 | 50 | /** The list of managed animators. 51 | * 52 | * @default 53 | * @type {module:player/Animator.Animator[]} 54 | */ 55 | const animatorList = []; 56 | 57 | /** The main animation loop. 58 | * 59 | * This function is called periodically and triggers the 60 | * animation steps in all running animators. 61 | * 62 | * If all animators are removed from the list of running animators, 63 | * then the periodic calling is disabled. 64 | * 65 | * This function can be called either through `doRequestAnimationFrame` 66 | * or through `setInterval`. 67 | */ 68 | function loop() { 69 | if (runningAnimators > 0) { 70 | // If there is at least one animator, 71 | // and if the browser provides animation frames, 72 | // schedule this function to be called again in the next frame. 73 | if (doRequestAnimationFrame) { 74 | doRequestAnimationFrame(loop); 75 | } 76 | 77 | // Step all animators. We iterate over a copy of the animator list 78 | // in case the step() method removes an animator from the list. 79 | for (let animator of animatorList) { 80 | if (animator.running) { 81 | animator.step(); 82 | } 83 | } 84 | } 85 | else if (!doRequestAnimationFrame) { 86 | // If all animators have been removed, 87 | // and if this function is called periodically 88 | // by setInterval(), disable the periodic calling. 89 | window.clearInterval(timer); 90 | } 91 | } 92 | 93 | /** Start the animation loop. 94 | * 95 | * This function delegates the periodic update of all animators 96 | * to the `loop` function, either using `doRequestAnimationFrame` 97 | * if the browser supports it, or using `setInterval`. 98 | */ 99 | function start() { 100 | if (doRequestAnimationFrame) { 101 | doRequestAnimationFrame(loop); 102 | } 103 | else { 104 | timer = window.setInterval(loop, TIME_STEP_MS); 105 | } 106 | } 107 | 108 | /** Fired by an animator on each animation step. 109 | * 110 | * @event module:player/Animator.step 111 | */ 112 | 113 | /** Fired by an animator when stopping an animation before completion. 114 | * 115 | * @event module:player/Animator.stop 116 | */ 117 | 118 | /** Fired by an animator when an animation is complete. 119 | * 120 | * @event module:player/Animator.done 121 | */ 122 | 123 | /** An animator provides the logic for animating other objects. 124 | * 125 | * The main purpose of an animator is to schedule the update 126 | * operations in the animated objects. 127 | * 128 | * @extends EventEmitter 129 | */ 130 | export class Animator extends EventEmitter { 131 | /** Initialize a new animator. 132 | * 133 | * The new animator is added to the list of animators 134 | * managed by the Sozi player. 135 | */ 136 | constructor() { 137 | super(); 138 | 139 | /** The duration of the current animation, in milliseconds. 140 | * 141 | * @default 142 | * @type {number} 143 | */ 144 | this.durationMs = 500; 145 | 146 | /** The start time of the current animation. 147 | * 148 | * @default 149 | * @type {number} 150 | */ 151 | this.initialTime = 0; 152 | 153 | /** The current running state of this animator. 154 | * 155 | * @default 156 | * @type {boolean} 157 | */ 158 | this.running = false; 159 | 160 | animatorList.push(this); 161 | } 162 | 163 | /** Start a new animation. 164 | * 165 | * The {@linkcode module:player/Animator.step|step} event is fired once before starting the animation. 166 | * 167 | * @param {number} durationMs - The duration of the animation, in milliseconds. 168 | * 169 | * @fires module:player/Animator.step 170 | */ 171 | start(durationMs) { 172 | this.durationMs = durationMs; 173 | this.initialTime = perf.now(); 174 | this.emit("step", 0); 175 | if (!this.running) { 176 | this.running = true; 177 | runningAnimators ++; 178 | if (runningAnimators === 1) { 179 | start(); 180 | } 181 | } 182 | } 183 | 184 | /** Stop the current animation. 185 | * 186 | * @fires module:player/Animator.step 187 | */ 188 | stop() { 189 | if (this.running) { 190 | this.running = false; 191 | runningAnimators --; 192 | this.emit("stop"); 193 | } 194 | } 195 | 196 | /** Perform one animation step. 197 | * 198 | * This function is called automatically by the main animation loop. 199 | * It fires the {@linkcode module:player/Animator.step|step} event with 200 | * an indication of the current progress (elapsed time / duration). 201 | * 202 | * If the animation duration has elapsed, the 203 | * {@linkcode module:player/Animator.done|done} event is fired. 204 | * 205 | * @fires module:player/Animator.step 206 | * @fires module:player/Animator.done 207 | */ 208 | step() { 209 | const elapsedTime = perf.now() - this.initialTime; 210 | if (elapsedTime >= this.durationMs) { 211 | this.emit("step", 1); 212 | this.running = false; 213 | runningAnimators --; 214 | this.emit("done"); 215 | } else { 216 | this.emit("step", elapsedTime / this.durationMs); 217 | } 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/js/player/FrameList.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** Show/hide the frame list. 6 | * 7 | * This module is part of the Sozi player embedded in each presentation. 8 | * 9 | * @module 10 | */ 11 | 12 | import {Animator} from "./Animator"; 13 | import * as Timing from "./Timing"; 14 | 15 | /** The duration of the open/close animation of the frame list. 16 | * 17 | * @readonly 18 | * @type {number} 19 | */ 20 | const DURATION_MS = 500; 21 | 22 | /** The HTML element that contains the frame list. 23 | * 24 | * @type {HTMLElement} 25 | */ 26 | let frameList; 27 | 28 | /** The HTML links to each frame in the frame list. 29 | * 30 | * @type {HTMLAnchorElement[]} 31 | */ 32 | let links; 33 | 34 | /** The current Sozi player. 35 | * 36 | * @type {module:player/Player.Player} 37 | */ 38 | let player; 39 | 40 | /** An animator to open/close the frame list. 41 | * 42 | * @type {module:player/Animator.Animator} 43 | */ 44 | let animator; 45 | 46 | /** The current open status of the frame list. 47 | * 48 | * @default 49 | * @type {boolean} 50 | */ 51 | let isOpen = false; 52 | 53 | /** The start location of the frame list with respect to the left border of the viewport. 54 | * 55 | * 1 represents the width of the frame list. 56 | * 57 | * @default 58 | * @type {number} 59 | */ 60 | let startOffset = -1; 61 | 62 | /** The start location of the frame list with respect to the left border of the viewport. 63 | * 64 | * 1 represents the width of the frame list. 65 | * 66 | * @default 67 | * @type {number} 68 | */ 69 | let endOffset = -1; 70 | 71 | /** The current location of the frame list with respect to the left border of the viewport. 72 | * 73 | * 1 represents the width of the frame list. 74 | * 75 | * @default 76 | * @type {number} 77 | */ 78 | let currentOffset = startOffset; 79 | 80 | /** Initialize the frame list management. 81 | * 82 | * This function creates an {@linkcode module:player/Animator.Animator|animator} 83 | * to manage the open/close animations and registers its {@linkcode module:player/Animator.step|step} 84 | * even handler. 85 | * 86 | * It registers a {@linkcode module:player/Player.frameChange|frameChange} event handler 87 | * to highlight the current frame title in the frame list. 88 | * 89 | * It also registers mouse and keyboard events related to the frame list. 90 | * 91 | * @param {module:player/Player.Player} p - The current Sozi player. 92 | * @param {module:player/PlayerController.PlayerController} controller - The current Sozi player controller. 93 | */ 94 | export function init(p, controller) { 95 | player = p; 96 | 97 | frameList = document.querySelector(".sozi-frame-list"); 98 | links = frameList.querySelectorAll("li a"); 99 | 100 | for (let link of links) { 101 | link.addEventListener("click", evt => { 102 | if (evt.button === 0) { 103 | controller.previewFrame(link.hash.slice(1)); 104 | evt.preventDefault(); 105 | } 106 | }); 107 | } 108 | 109 | animator = new Animator(); 110 | animator.on("step", onAnimatorStep); 111 | window.addEventListener("keypress", onKeyPress, false); 112 | window.addEventListener("resize", () => setCurrentOffset(currentOffset)); 113 | controller.on("mouseDown", onMouseDown); 114 | player.viewport.svgRoot.addEventListener("touchstart", evt => onTouchStart); 115 | frameList.addEventListener("mouseout", onMouseOut, false); 116 | p.on("frameChange", onFrameChange); 117 | setCurrentOffset(startOffset); 118 | } 119 | 120 | /** Set the location of the frame list with respect to the left border of the viewport. 121 | * 122 | * 1 represents the width of the frame list. 123 | * 124 | * @param {number} offset - The new location. 125 | */ 126 | function setCurrentOffset(offset) { 127 | currentOffset = offset; 128 | frameList.style.left = currentOffset * frameList.offsetWidth + "px"; 129 | } 130 | 131 | /** Move the frame list to the given location, with an animation. 132 | * 133 | * 1 represents the width of the frame list. 134 | * 135 | * @param {number} offset - The target location. 136 | */ 137 | function moveTo(offset) { 138 | player.pause(); 139 | startOffset = currentOffset; 140 | endOffset = offset; 141 | animator.start(Math.abs(endOffset - startOffset) * DURATION_MS); 142 | } 143 | 144 | /** Open the frame list. */ 145 | export function open() { 146 | moveTo(0); 147 | } 148 | 149 | /** Close the frame list. */ 150 | export function close() { 151 | moveTo(-1); 152 | } 153 | 154 | /** Toggle the open/closed status of the frame list. */ 155 | export function toggle() { 156 | moveTo(-1 - endOffset); 157 | } 158 | 159 | /** Process a keypress event related to the frame list. 160 | * 161 | * If enabled by the presentation, pressing the key `T` will toggle the open/close status of the frame list. 162 | * 163 | * @param {KeyboardEvent} evt - The DOM event representing the keypress. 164 | * 165 | * @listens keypress 166 | */ 167 | function onKeyPress(evt) { 168 | // Keys with modifiers are ignored 169 | if (evt.altKey || evt.ctrlKey || evt.metaKey) { 170 | return; 171 | } 172 | 173 | switch (evt.charCode || evt.which) { 174 | case 84: // T 175 | case 116: // t 176 | if (player.presentation.enableKeyboardNavigation) { 177 | player.disableBlankScreen(); 178 | toggle(); 179 | } 180 | break; 181 | default: 182 | return; 183 | } 184 | 185 | evt.stopPropagation(); 186 | evt.preventDefault(); 187 | } 188 | 189 | /** Perform an animation step while moving the frame list. 190 | * 191 | * This function is called by the current animator. 192 | * 193 | * @param {number} progress - The current progress indicator, between 0 and 1. 194 | * 195 | * @listens module:player/Animator.step 196 | */ 197 | function onAnimatorStep(progress) { 198 | const p = Timing.ease(progress); 199 | setCurrentOffset(endOffset * p + startOffset * (1 - p)); 200 | } 201 | 202 | /** Process a mouse-down event. 203 | * 204 | * If enabled by the presentation, pressing the middle mouse button will 205 | * toggle the open/close status of the frame list. 206 | * 207 | * @param {number} button - The index of the button that was pressed. 208 | * 209 | * @listens module:player/PlayerController.mouseDown 210 | */ 211 | function onMouseDown(button) { 212 | if (player.presentation.enableMouseNavigation && button === 1) { 213 | toggle(); 214 | } 215 | } 216 | 217 | /** Process a touch event on touch devices. 218 | * 219 | * Closes the Frame List on any touch outside of the list itself. 220 | * 221 | * @param {TouchEvent} evt - the touch event. 222 | * 223 | * @listens touchstart 224 | */ 225 | function onTouchStart(evt) { 226 | close(); 227 | } 228 | 229 | /** Process a mouse-out event. 230 | * 231 | * When the mouse cursor moves out of the frame list area, 232 | * this function closes it. 233 | * 234 | * @param {MouseEvent} evt - The DOM event representing the mouse gesture. 235 | * 236 | * @listens mouseout 237 | */ 238 | function onMouseOut(evt) { 239 | let rel = evt.relatedTarget; 240 | while (rel && rel !== frameList && rel !== document.documentElement) { 241 | rel = rel.parentNode; 242 | } 243 | if (rel !== frameList) { 244 | close(); 245 | evt.stopPropagation(); 246 | } 247 | } 248 | 249 | /** Highlight the current frame in the frame list. 250 | * 251 | * @listens module:player/Player.frameChange 252 | */ 253 | function onFrameChange() { 254 | for (let link of links) { 255 | link.className = link.hash === "#" + player.currentFrame.frameId ? 256 | "current" : 257 | ""; 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/js/player/FrameNumber.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** Show the current frame number. 6 | * 7 | * This module is part of the Sozi player embedded in each presentation. 8 | * 9 | * @module 10 | */ 11 | 12 | import * as FrameList from "./FrameList"; 13 | 14 | /** Initialize the frame number management. 15 | * 16 | * This function registers event handlers: 17 | * - A {@linkcode module:player/Player.frameChange|frameChange} event from the Sozi player will update the frame number. 18 | * - A `click` event on the frame number will open the frame list. 19 | * 20 | * @param {module:player/Player.Player} player - The current Sozi player. 21 | */ 22 | export function init(player) { 23 | const frameNumber = document.querySelector(".sozi-frame-number"); 24 | 25 | player.on("frameChange", () => { 26 | frameNumber.innerHTML = player.currentFrame.index + 1; 27 | frameNumber.style.visibility = player.currentFrame.showFrameNumber ? "visible" : "hidden"; 28 | }); 29 | 30 | frameNumber.addEventListener("click", FrameList.open); 31 | } 32 | -------------------------------------------------------------------------------- /src/js/player/FrameURL.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** Manage the browser's location bar while playing a presentation. 6 | * 7 | * This module is part of the Sozi player embedded in each presentation. 8 | * 9 | * @module 10 | */ 11 | 12 | /** The current Sozi player. 13 | * 14 | * @type {module:player/Player.Player} 15 | */ 16 | let player; 17 | 18 | /** Initialize the location bar management. 19 | * 20 | * This function registers `hashchange` and 21 | * {@linkcode module:player/Player.frameChange|frameChange} event handlers 22 | * to reflect the current frame ID in the current URL. 23 | * 24 | * @param {module:player/Player.Player} p - The current Sozi player. 25 | */ 26 | export function init(p) { 27 | player = p; 28 | 29 | window.addEventListener("hashchange", onHashChange, false); 30 | 31 | if (player.presentation.updateURLOnFrameChange) { 32 | player.on("frameChange", onFrameChange); 33 | } 34 | } 35 | 36 | /** Get the frame for the current URL. 37 | * 38 | * This function parses the current URL hash as a frame ID or a frame number. 39 | * It returns the corresponding frame, or the current frame if no match was found. 40 | * 41 | * @returns {module:model/Presentation.Frame} - The frame that corresponds to the current URL hash. 42 | */ 43 | export function getFrame() { 44 | if (window.location.hash) { 45 | const indexOrId = window.location.hash.slice(1); 46 | const frame = player.presentation.getFrameWithId(indexOrId); 47 | if (frame) { 48 | return frame; 49 | } 50 | else { 51 | const index = parseInt(indexOrId); 52 | return !isNaN(index) && index > 0 && index <= player.presentation.frames.length ? 53 | player.presentation.frames[index - 1] : 54 | player.currentFrame; 55 | } 56 | } 57 | else { 58 | return player.currentFrame; 59 | } 60 | } 61 | 62 | /** Process the `hashchange` event. 63 | * 64 | * Move the presentation to the frame that corresponds to the current URL. 65 | * 66 | * @listens hashchange 67 | */ 68 | function onHashChange() { 69 | const frame = getFrame(); 70 | if (player.currentFrame !== frame) { 71 | player.moveToFrame(frame); 72 | } 73 | } 74 | 75 | /** Update the URL hash in the location bar on frame change. 76 | * 77 | * @listens module:player/Player.frameChange 78 | */ 79 | function onFrameChange() { 80 | window.location.hash = "#" + player.currentFrame.frameId; 81 | } 82 | -------------------------------------------------------------------------------- /src/js/player/Media.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** Manage the video or audio elements embedded in a presentation. 6 | * 7 | * This module is part of the Sozi player embedded in each presentation. 8 | * 9 | * @module 10 | */ 11 | 12 | /** The SVG namespace URI. 13 | * 14 | * @readonly 15 | * @type {string} 16 | */ 17 | const svgNs = "http://www.w3.org/2000/svg"; 18 | 19 | /** The Sozi namespace URI. 20 | * 21 | * @readonly 22 | * @type {string} 23 | */ 24 | const soziNs = "http://sozi.baierouge.fr"; 25 | 26 | /** The XHTML namespace URI. 27 | * 28 | * @readonly 29 | * @type {string} 30 | */ 31 | const xhtmlNs = "http://www.w3.org/1999/xhtml"; 32 | 33 | /** The current Sozi player. 34 | * 35 | * @type {module:player/Player.Player} 36 | */ 37 | let player; 38 | 39 | /** A default event handler that prevents the propagation of an event. 40 | * 41 | * For instance, this function prevents a click event inside a video element 42 | * from also triggering a transition in the current presentation. 43 | * 44 | * @param {Event} evt - The DOM event to stop. 45 | * 46 | * @listens click 47 | * @listens mousedown 48 | * @listens mouseup 49 | * @listens mousemove 50 | * @listens contextmenu 51 | */ 52 | function defaultEventHandler(evt) { 53 | evt.stopPropagation(); 54 | } 55 | 56 | /** A dictionary of video and audio elements to start in each frame. 57 | * 58 | * @type {object} 59 | */ 60 | const mediaToStartByFrameId = {}; 61 | 62 | /** A dictionary of video and audio elements to stop in each frame. 63 | * 64 | * @type {object} 65 | */ 66 | const mediaToStopByFrameId = {}; 67 | 68 | /** Start or stop media on frame change. 69 | * 70 | * @listens module:player/Player.frameChange 71 | */ 72 | function onFrameChange() { 73 | if (!player.mediaEnable) { 74 | return; 75 | } 76 | const frameId = player.currentFrame.frameId; 77 | if (frameId in mediaToStartByFrameId) { 78 | for (let m of mediaToStartByFrameId[frameId]) { 79 | m.play(); 80 | } 81 | } 82 | if (frameId in mediaToStopByFrameId) { 83 | for (let m of mediaToStopByFrameId[frameId]) { 84 | m.pause(); 85 | } 86 | } 87 | } 88 | 89 | /** Initialize the video and audio element management. 90 | * 91 | * This function transforms custom XML `video` and `audio` into their 92 | * HTML counterparts. 93 | * 94 | * It extracts the start/stop frame information for each media element, 95 | * and registers a {@linkcode module:player/Player.frameChange|frameChange} event handler 96 | * to start and stop media in the appropriate frames. 97 | * 98 | * @param {module:player/Player.Player} p - The current Sozi player. 99 | */ 100 | export function init(p) { 101 | player = p; 102 | 103 | player.on("frameChange", onFrameChange); 104 | 105 | // Find namespace prefix for Sozi. 106 | // Inlining SVG inside HTML does not allow to use 107 | // namespace-aware DOM methods. 108 | const svgRoot = player.presentation.document.root; 109 | const svgAttributes = svgRoot.attributes; 110 | let soziPrefix; 111 | for (let attrIndex = 0; attrIndex < svgAttributes.length; attrIndex ++) { 112 | if (svgAttributes[attrIndex].value === soziNs) { 113 | soziPrefix = svgAttributes[attrIndex].name.slice(6); 114 | break; 115 | } 116 | } 117 | 118 | if (!soziPrefix) { 119 | return; 120 | } 121 | 122 | // Get custom video and audio elements 123 | const videoSources = Array.from(svgRoot.getElementsByTagName(soziPrefix + ":video")); 124 | const audioSources = Array.from(svgRoot.getElementsByTagName(soziPrefix + ":audio")); 125 | 126 | // Replace them with HTML5 audio and video elements 127 | const mediaList = []; 128 | for (let source of videoSources.concat(audioSources)) { 129 | const rect = source.parentNode; 130 | const tagName = source.localName.slice(soziPrefix.length + 1); 131 | 132 | // Create HTML media source element 133 | const htmlSource = document.createElementNS(xhtmlNs, "source"); 134 | htmlSource.setAttribute("type", source.getAttribute(soziPrefix + ":type")); 135 | htmlSource.setAttribute("src", source.getAttribute(soziPrefix + ":src")); 136 | 137 | let j; 138 | for (j = 0; j < mediaList.length; j += 1) { 139 | if (mediaList[j].rect === rect) { 140 | break; 141 | } 142 | } 143 | 144 | if (j === mediaList.length) { 145 | rect.setAttribute("visibility", "hidden"); 146 | 147 | const width = rect.getAttribute("width"); 148 | const height = rect.getAttribute("height"); 149 | 150 | // Create HTML media element 151 | const htmlMedia = document.createElementNS(xhtmlNs, tagName); 152 | if (source.getAttribute(soziPrefix + ":controls") === "true") { 153 | htmlMedia.setAttribute("controls", "controls"); 154 | htmlMedia.setAttribute("style", `width:${width}px;height:${height}px;`); 155 | } 156 | if (tagName === "video") { 157 | htmlMedia.setAttribute("width", width); 158 | htmlMedia.setAttribute("height", height); 159 | } 160 | htmlMedia.addEventListener("click", defaultEventHandler, false); 161 | htmlMedia.addEventListener("mousedown", defaultEventHandler, false); 162 | htmlMedia.addEventListener("mouseup", defaultEventHandler, false); 163 | htmlMedia.addEventListener("mousemove", defaultEventHandler, false); 164 | htmlMedia.addEventListener("contextmenu", defaultEventHandler, false); 165 | 166 | // Create HTML root element 167 | const html = document.createElementNS(xhtmlNs, "html"); 168 | html.appendChild(htmlMedia); 169 | 170 | // Create SVG foreign object 171 | const foreignObject = document.createElementNS(svgNs, "foreignObject"); 172 | foreignObject.setAttribute("x", rect.getAttribute("x")); 173 | foreignObject.setAttribute("y", rect.getAttribute("y")); 174 | foreignObject.setAttribute("width", width); 175 | foreignObject.setAttribute("height", height); 176 | foreignObject.appendChild(html); 177 | 178 | rect.parentNode.insertBefore(foreignObject, rect.nextSibling); 179 | 180 | if (source.hasAttribute(soziPrefix + ":start-frame")) { 181 | const startFrameId = source.getAttribute(soziPrefix + ":start-frame"); 182 | const stopFrameId = source.getAttribute(soziPrefix + ":stop-frame"); 183 | if (!(startFrameId in mediaToStartByFrameId)) { 184 | mediaToStartByFrameId[startFrameId] = []; 185 | } 186 | if (!(stopFrameId in mediaToStopByFrameId)) { 187 | mediaToStopByFrameId[stopFrameId] = []; 188 | } 189 | mediaToStartByFrameId[startFrameId].push(htmlMedia); 190 | mediaToStopByFrameId[stopFrameId].push(htmlMedia); 191 | } 192 | 193 | if (source.getAttribute(soziPrefix + ":loop") === "true") { 194 | htmlMedia.setAttribute("loop", "true"); 195 | } 196 | 197 | mediaList.push({ 198 | rect: source.parentNode, 199 | htmlMedia 200 | }); 201 | } 202 | 203 | // Append HTML source element to current HTML media element 204 | mediaList[j].htmlMedia.appendChild(htmlSource); 205 | } 206 | } 207 | 208 | /** Disable video and audio support in the current presentation. 209 | * 210 | * This function disables the {@linkcode module:player/Player.frameChange|frameChange} event handler 211 | * and pauses all playing videos. 212 | */ 213 | export function disable() { 214 | player.off("frameChange", onFrameChange); 215 | 216 | const frameId = player.currentFrame.frameId; 217 | if (frameId in mediaToStartByFrameId) { 218 | for (let m of mediaToStartByFrameId[frameId]) { 219 | m.pause(); 220 | } 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/js/player/Timing.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** Timing functions for transitions. 6 | * 7 | * This module is based on the article by Gaëtan Renaudeau, with code released under an MIT license. 8 | * 9 | * @module 10 | * @see {@link http://greweb.me/2012/02/bezier-curve-based-easing-functions-from-concept-to-implementation/ Bézier Curve based easing functions – from concept to implementation} 11 | */ 12 | 13 | /** Helper function to compute a Bézier curve. 14 | * 15 | * @param {number} xy1 - X or Y coordinate of the first control point. 16 | * @param {number} xy2 - X or Y coordinate of the second control point. 17 | * @returns {number} - A linear combination of the arguments. 18 | */ 19 | function A(xy1, xy2) { 20 | return 1.0 - 3.0 * xy2 + 3.0 * xy1; 21 | } 22 | 23 | /** Helper function to compute a Bézier curve. 24 | * 25 | * @param {number} xy1 - X or Y coordinate of the first control point. 26 | * @param {number} xy2 - X or Y coordinate of the second control point. 27 | * @returns {number} - A linear combination of the arguments. 28 | */ 29 | function B(xy1, xy2) { 30 | return 3.0 * xy2 - 6.0 * xy1; 31 | } 32 | 33 | /** Helper function to compute a Bézier curve. 34 | * 35 | * @param {number} xy1 - X or Y coordinate of the first control point. 36 | * @returns {number} - A linear combination of the arguments. 37 | */ 38 | function C(xy1) { 39 | return 3.0 * xy1; 40 | } 41 | 42 | /** Compute a coordinate of a point of the Bézier curve. 43 | * 44 | * @param {number} t - The location of the point along the curve. 45 | * @param {number} a - The output of function `A` for the control points of the curve. 46 | * @param {number} b - The output of function `B` for the control points of the curve. 47 | * @param {number} c - The output of function `C` for the control points of the curve. 48 | * @returns {number} - The X or Y coordinate of a point of the Bézier curve. 49 | */ 50 | function bezier(t, a, b, c) { 51 | return ((a * t + b) * t + c) * t; 52 | } 53 | 54 | /** Compute the derivative of a coordinate at a point of the Bézier curve. 55 | * 56 | * @param {number} t - The location of the point along the curve. 57 | * @param {number} a - The output of function `A` for the control points of the curve. 58 | * @param {number} b - The output of function `B` for the control points of the curve. 59 | * @param {number} c - The output of function `C` for the control points of the curve. 60 | * @returns {number} - The derivative dX/dt or dY/dt of a coordinate at a point of the Bézier curve. 61 | */ 62 | function bezierSlope(t, a, b, c) { 63 | return (3.0 * a * t + 2.0 * b) * t + c; 64 | } 65 | 66 | /** Create a Bézier curve with the given control points. 67 | * 68 | * @param {number} x1 - The X coordinate of the first control point. 69 | * @param {number} y1 - The Y coordinate of the first control point. 70 | * @param {number} x2 - The X coordinate of the second control point. 71 | * @param {number} y2 - The Y coordinate of the second control point. 72 | * @returns {Function} - A function that takes a coordinates X and computes the Y coordinate of the corresponding point of the Bézier curve. 73 | */ 74 | export function makeBezier(x1, y1, x2, y2) { 75 | const ax = A(x1, x2), bx = B(x1, x2), cx = C(x1); 76 | const ay = A(y1, y2), by = B(y1, y2), cy = C(y1); 77 | 78 | if (x1 === y1 && x2 === y2) { 79 | // Linear 80 | return function (x) { 81 | return x; 82 | }; 83 | } 84 | 85 | return function(x) { 86 | // Newton raphson iteration 87 | let t = x; 88 | for (let i = 0; i < 4; i++) { 89 | const currentSlope = bezierSlope(t, ax, bx, cx); 90 | if (currentSlope === 0.0) { 91 | break; 92 | } 93 | const currentX = bezier(t, ax, bx, cx) - x; 94 | t -= currentX / currentSlope; 95 | } 96 | return bezier(t, ay, by, cy); 97 | }; 98 | } 99 | 100 | /** Create a staircase function. 101 | * 102 | * @param {number} n - The number of steps. 103 | * @param {boolean} start - Step at the beginning or at the end of each interval? 104 | * @returns {Function} - A function that takes a coordinates X and computes the Y coordinate of the corresponding point of the step function. 105 | */ 106 | export function makeSteps(n, start) { 107 | const trunc = start ? Math.ceil : Math.floor; 108 | return function (x) { 109 | return trunc(n * x) / n; 110 | }; 111 | } 112 | 113 | /** A linear timing function. 114 | * 115 | * @param {number} t - The current time, between 0 and 1. 116 | * @returns {number} - The actual progress, between 0 and 1. 117 | */ 118 | export const linear = makeBezier(0.0, 0.0, 1.0, 1.0); 119 | linear.reverse = linear; 120 | 121 | /** An easing timing function. 122 | * 123 | * @param {number} t - The current time, between 0 and 1. 124 | * @returns {number} - The actual progress, between 0 and 1. 125 | */ 126 | export const ease = makeBezier(0.25, 0.1, 0.25, 1.0); 127 | ease.reverse = ease; 128 | 129 | /** An ease-in timing function. 130 | * 131 | * @param {number} t - The current time, between 0 and 1. 132 | * @returns {number} - The actual progress, between 0 and 1. 133 | */ 134 | export const easeIn = makeBezier(0.42, 0.0, 1.0, 1.0); 135 | 136 | /** An ease-out timing function. 137 | * 138 | * @param {number} t - The current time, between 0 and 1. 139 | * @returns {number} - The actual progress, between 0 and 1. 140 | */ 141 | export const easeOut = makeBezier(0.0, 0.0, 0.58, 1.0); 142 | 143 | easeIn.reverse = easeOut; 144 | easeOut.reverse = easeIn; 145 | 146 | /** A timing function with ease-in and ease-out. 147 | * 148 | * @param {number} t - The current time, between 0 and 1. 149 | * @returns {number} - The actual progress, between 0 and 1. 150 | */ 151 | export const easeInOut = makeBezier(0.42, 0.0, 0.58, 1.0); 152 | easeInOut.reverse = easeInOut; 153 | 154 | /** A single immediate step timing function. 155 | * 156 | * @param {number} t - The current time, between 0 and 1. 157 | * @returns {number} - The actual progress, between 0 and 1. 158 | */ 159 | export const stepStart = makeSteps(1, true); 160 | 161 | /** A single final step timing function. 162 | * 163 | * @param {number} t - The current time, between 0 and 1. 164 | * @returns {number} - The actual progress, between 0 and 1. 165 | */ 166 | export const stepEnd = makeSteps(1, false); 167 | 168 | stepStart.reverse = stepEnd; 169 | stepEnd.reverse = stepStart; 170 | 171 | /** A middle step timing function. 172 | * 173 | * @param {number} t - The current time, between 0 and 1. 174 | * @returns {number} - The actual progress, between 0 and 1. 175 | */ 176 | export function stepMiddle(t) { 177 | return t >= 0.5 ? 1 : 0; 178 | } 179 | stepMiddle.reverse = stepMiddle; 180 | -------------------------------------------------------------------------------- /src/js/presenter.js: -------------------------------------------------------------------------------- 1 | 2 | let previews = [ 3 | { index: 0 }, 4 | { index: -1 }, 5 | { index: 1 } 6 | ]; 7 | 8 | let presWindow; 9 | let presLength = 0; 10 | 11 | function updatePreview(p) { 12 | if (p.index >= 0 && p.index < presLength) { 13 | p.window.postMessage({name: "jumpToFrame", args: [p.index]}, "*"); 14 | } 15 | else { 16 | p.window.postMessage({name: "enableBlankScreen"}, "*"); 17 | } 18 | } 19 | 20 | function updateFrameData({index, title, notes}) { 21 | document.querySelector(".sozi-current-index").innerHTML = index + 1; 22 | document.querySelector(".sozi-presentation-length").innerHTML = presLength; 23 | document.querySelector(".sozi-notes-title").innerHTML = title; 24 | if (typeof notes === "string") { 25 | document.querySelector(".sozi-notes-body").innerHTML = notes; 26 | } 27 | } 28 | 29 | window.addEventListener("message", evt => { 30 | switch (evt.data.name) { 31 | case "loaded": 32 | presLength = evt.data.length; 33 | if (evt.source === presWindow) { 34 | evt.source.postMessage({name: "notifyOnFrameChange"}, "*"); 35 | } 36 | else { 37 | evt.source.postMessage({ 38 | name: "setPresenterMode", 39 | isCurrent: evt.source === previews[0].window 40 | }, "*"); 41 | } 42 | break; 43 | 44 | case "frameChange": 45 | previews[0].index = evt.data.index; 46 | previews[1].index = evt.data.index - 1; 47 | previews[2].index = evt.data.index + 1; 48 | previews.forEach(updatePreview); 49 | updateFrameData(evt.data); 50 | break; 51 | 52 | case "click": 53 | presWindow.postMessage({name: "click", id: evt.data.id}, "*"); 54 | break; 55 | } 56 | }); 57 | 58 | window.addEventListener("load", () => { 59 | const iframes = document.querySelectorAll("iframe"); 60 | 61 | // Initialize the iframes that show the previous, current, and next frames. 62 | previews.forEach((p, i) => { 63 | p.window = iframes[i].contentWindow; 64 | }); 65 | 66 | // Prevent the notes pane to change size when its content is updated. 67 | const preview = document.querySelector(".sozi-frame-preview"); 68 | const notes = document.querySelector(".sozi-notes"); 69 | new ResizeObserver(() => { 70 | notes.style.width = `calc(100vw - ${preview.offsetWidth}px`; 71 | }).observe(preview); 72 | 73 | // Open a new window for the main presentation view. 74 | presWindow = window.open(iframes[0].src, "sozi-presentation", "width=600, height=400, scrollbars=yes, toolbar=yes"); 75 | try { 76 | presWindow.focus(); 77 | 78 | document.getElementById("sozi-previous-btn").addEventListener("click", () => { 79 | presWindow.postMessage({name: "moveToPrevious"}, "*"); 80 | }, false); 81 | 82 | document.getElementById("sozi-next-btn").addEventListener("click", () => { 83 | presWindow.postMessage({name: "moveToNext"}, "*"); 84 | }, false); 85 | } 86 | catch (e) { 87 | alert("Could not open presentation window. Please allow popups for this site and refresh this page."); 88 | } 89 | }, false); 90 | 91 | window.addEventListener("keydown", evt => { 92 | // Keys with Alt/Ctrl/Meta modifiers are ignored 93 | if (evt.altKey || evt.ctrlKey || evt.metaKey) { 94 | return; 95 | } 96 | 97 | switch (evt.keyCode) { 98 | case 36: // Home 99 | if (evt.shiftKey) { 100 | presWindow.postMessage({name: "jumpToFirst"}, "*"); 101 | } 102 | else { 103 | presWindow.postMessage({name: "moveToFirst"}, "*"); 104 | } 105 | break; 106 | 107 | case 35: // End 108 | if (evt.shiftKey) { 109 | presWindow.postMessage({name: "jumpToLast"}, "*"); 110 | } 111 | else { 112 | presWindow.postMessage({name: "moveToLast"}, "*"); 113 | } 114 | break; 115 | 116 | case 38: // Arrow up 117 | case 33: // Page up 118 | case 37: // Arrow left 119 | if (evt.shiftKey) { 120 | presWindow.postMessage({name: "jumpToPrevious"}, "*"); 121 | } 122 | else { 123 | presWindow.postMessage({name: "moveToPrevious"}, "*"); 124 | } 125 | break; 126 | 127 | case 40: // Arrow down 128 | case 34: // Page down 129 | case 39: // Arrow right 130 | case 13: // Enter 131 | case 32: // Space 132 | if (evt.shiftKey) { 133 | presWindow.postMessage({name: "jumpToNext"}, "*"); 134 | } 135 | else { 136 | presWindow.postMessage({name: "moveToNext"}, "*"); 137 | } 138 | break; 139 | 140 | default: 141 | return; 142 | } 143 | 144 | evt.stopPropagation(); 145 | evt.preventDefault(); 146 | }); 147 | 148 | window.addEventListener("keypress", evt => { 149 | // Keys with modifiers are ignored 150 | if (evt.altKey || evt.ctrlKey || evt.metaKey) { 151 | return; 152 | } 153 | 154 | switch (evt.charCode || evt.which) { 155 | case 46: // . 156 | presWindow.postMessage({name: "toggleBlankScreen"}, "*"); 157 | break; 158 | 159 | default: 160 | return; 161 | } 162 | 163 | evt.stopPropagation(); 164 | evt.preventDefault(); 165 | }); 166 | -------------------------------------------------------------------------------- /src/js/svg/AiHandler.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** @module */ 6 | 7 | import {addSVGHandler, DefaultSVGHandler} from "./SVGDocumentWrapper"; 8 | 9 | /** Adobe Illustrator SVG handler. 10 | * 11 | * @extends module:svg/DefaultSVGHandler.DefaultSVGHandler 12 | */ 13 | export class AiHandler extends DefaultSVGHandler { 14 | 15 | /** @inheritdoc */ 16 | static matches(svgRoot) { 17 | return /^http:\/\/ns.adobe.com\/AdobeIllustrator/.test(svgRoot.getAttribute("xmlns:i")) && 18 | Array.from(svgRoot.childNodes).some(svgNode => svgNode instanceof SVGSwitchElement); 19 | } 20 | 21 | /** @inheritdoc */ 22 | static transform(svgRoot) { 23 | for (let svgSwitch of Array.from(svgRoot.getElementsByTagName("switch"))) { 24 | // Remove first foreignObject child node 25 | const svgForeignObject = svgSwitch.firstElementChild; 26 | if (svgForeignObject && svgForeignObject instanceof SVGForeignObjectElement && 27 | svgForeignObject.hasAttribute("requiredExtensions") && 28 | svgForeignObject.getAttribute("requiredExtensions").startsWith("http://ns.adobe.com/AdobeIllustrator")) { 29 | // Remove foreign objet element 30 | svgSwitch.removeChild(svgForeignObject); 31 | 32 | // Unwrap main group 33 | let svgGroup = svgSwitch.firstElementChild; 34 | if (!svgGroup || svgGroup instanceof SVGGElement || svgGroup.getAttribute("i:extraneous") !== "self") { 35 | svgGroup = svgSwitch; 36 | } 37 | // Make a copy of svgGroup.childNodes before modifying the document. 38 | for (let childNode of Array.from(svgGroup.childNodes)) { 39 | svgSwitch.parentNode.insertBefore(childNode, svgSwitch); 40 | } 41 | 42 | // Remove switch element 43 | svgSwitch.parentNode.removeChild(svgSwitch); 44 | } 45 | } 46 | } 47 | 48 | /** @inheritdoc */ 49 | static isLayer(svgElement) { 50 | return svgElement.getAttribute("i:layer") === "yes"; 51 | } 52 | } 53 | 54 | addSVGHandler("Adobe Illustrator", AiHandler); 55 | -------------------------------------------------------------------------------- /src/js/svg/ImpressHandler.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** @module */ 6 | 7 | import {addSVGHandler, DefaultSVGHandler} from "./SVGDocumentWrapper"; 8 | 9 | const SOFFICE_NS = "http://sun.com/xmlns/staroffice/presentation"; 10 | const OOO_NS = "http://xml.openoffice.org/svg/export"; 11 | 12 | /** LibreOffice Impress SVG handler. 13 | * 14 | * @extends module:svg/DefaultSVGHandler.DefaultSVGHandler 15 | */ 16 | export class ImpressHandler extends DefaultSVGHandler { 17 | 18 | /** @inheritdoc */ 19 | static matches(svgRoot) { 20 | return svgRoot.getAttribute("xmlns:presentation") === SOFFICE_NS && 21 | svgRoot.getAttribute("xmlns:ooo") === OOO_NS 22 | } 23 | 24 | /** @inheritdoc */ 25 | static transform(svgRoot) { 26 | const ds = svgRoot.querySelector("g.DummySlide"); 27 | if (ds) { 28 | ds.parentNode.removeChild(ds); 29 | } 30 | 31 | const ms = svgRoot.querySelector("g.Master_Slide"); 32 | if (ms) { 33 | ms.parentNode.removeChild(ms); 34 | } 35 | 36 | const sg = svgRoot.querySelector("g.SlideGroup"); 37 | const g = sg.querySelector("g.Slide"); 38 | sg.parentNode.replaceChild(g, sg); 39 | } 40 | } 41 | 42 | addSVGHandler("Impress", ImpressHandler); 43 | -------------------------------------------------------------------------------- /src/js/svg/InkscapeHandler.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** @module */ 6 | 7 | import {addSVGHandler, DefaultSVGHandler} from "./SVGDocumentWrapper"; 8 | 9 | /** The XML namespace URI of Inkscape. 10 | * 11 | * @readonly 12 | * @default 13 | * @type {string} 14 | */ 15 | const INKSCAPE_NS = "http://www.inkscape.org/namespaces/inkscape"; 16 | 17 | /** The XML namespace URI of Sodipodi. 18 | * 19 | * @readonly 20 | * @default 21 | * @type {string} 22 | */ 23 | const SODIPODI_NS = "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"; 24 | 25 | /** Inkscape SVG handler. 26 | * 27 | * @extends module:svg/DefaultSVGHandler.DefaultSVGHandler 28 | */ 29 | export class InkscapeHandler extends DefaultSVGHandler { 30 | 31 | /** @inheritdoc */ 32 | static matches(svgRoot) { 33 | return svgRoot.getAttribute("xmlns:inkscape") === INKSCAPE_NS; 34 | } 35 | 36 | /** @inheritdoc */ 37 | static transform(svgRoot) { 38 | let pageColor = "#ffffff"; 39 | let pageOpacity = "0"; 40 | 41 | // Get page color and opacity from Inkscape document properties 42 | const namedViews = svgRoot.getElementsByTagNameNS(SODIPODI_NS, "namedview"); 43 | for (let i = 0; i < namedViews.length; i ++) { 44 | if (namedViews[i].hasAttribute("pagecolor")) { 45 | pageColor = namedViews[i].getAttribute("pagecolor"); 46 | if (namedViews[i].hasAttributeNS(INKSCAPE_NS, "pageopacity")) { 47 | pageOpacity = namedViews[i].getAttributeNS(INKSCAPE_NS, "pageopacity"); 48 | } 49 | break; 50 | } 51 | } 52 | 53 | // Extract RGB assuming page color is in 6-digit hex format 54 | const [, red, green, blue] = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(pageColor); 55 | 56 | const style = document.createElement("style"); 57 | style.innerHTML = `svg { 58 | background: rgba(${parseInt(red, 16)}, ${parseInt(green, 16)}, ${parseInt(blue, 16)}, ${pageOpacity}); 59 | }`; 60 | svgRoot.insertBefore(style, svgRoot.firstChild); 61 | } 62 | 63 | /** @inheritdoc */ 64 | static isLayer(svgElement) { 65 | return svgElement.getAttribute("inkscape:groupmode") === "layer"; 66 | } 67 | 68 | /** @inheritdoc */ 69 | static getLabel(svgElement) { 70 | return svgElement.getAttribute("inkscape:label"); 71 | } 72 | } 73 | 74 | addSVGHandler("Inkscape", InkscapeHandler); 75 | -------------------------------------------------------------------------------- /src/js/svg/SVGDocumentWrapper.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** @module */ 6 | 7 | /** The SVG namespace URI. 8 | * 9 | * @readonly 10 | * @default 11 | * @type {string} 12 | */ 13 | const SVG_NS = "http://www.w3.org/2000/svg"; 14 | 15 | /** The names of the SVG elements recognized as "drawable". 16 | * 17 | * When isolated elements of these types are found, they are 18 | * automatically added to specific layers. 19 | * 20 | * @readonly 21 | * @type {string[]} 22 | */ 23 | const DRAWABLE_TAGS = [ "g", "image", "path", "rect", "circle", 24 | "ellipse", "line", "polyline", "polygon", "text", "clippath" ]; 25 | 26 | /** A dictionary of SVG handlers. 27 | * 28 | * @default 29 | * @type {object} 30 | */ 31 | const handlers = {}; 32 | 33 | /** Add an SVG handler to the dictionary of supported handlers. 34 | * 35 | * @param {string} name - The name of the handler to add. 36 | * @param {module:svg/SVGDocumentWrapper.DefaultSVGHandler} handler - The handler to add. 37 | */ 38 | export function addSVGHandler(name, handler) { 39 | handlers[name] = handler; 40 | } 41 | 42 | /** Base class for SVG handlers. 43 | * 44 | * An SVG handler provides support for SVG documents created by a given 45 | * authoring application. 46 | */ 47 | export class DefaultSVGHandler { 48 | /** Check that an SVG document has been created with a given application. 49 | * 50 | * @param {SVGSVGElement} svgRoot - The root SVG element to check. 51 | * @returns {boolean} - `true` if the given SVG root has been created by the application handled by this class. 52 | */ 53 | static matches(svgRoot) { 54 | return true; 55 | } 56 | 57 | /** Preprocess an SVG document. 58 | * 59 | * This method will transform an SVG document to make it suitable for 60 | * the Sozi editor. 61 | * 62 | * Typical transformations consist in removing unsupported XML elements, 63 | * or fixing properties that could conflict with Sozi. 64 | * 65 | * @param {SVGSVGElement} svgRoot - The root SVG element to check. 66 | */ 67 | static transform(svgRoot) { 68 | } 69 | 70 | /** Check whether an SVG group represents a layer. 71 | * 72 | * The concept of layer is not specified in the SVG standard. 73 | * This method will check the given element against the implementation 74 | * of layers according to a given application. 75 | * 76 | * @param {SVGGElement} svgElement - A group to check. 77 | * @returns {boolean} - `true` if the given element represents a layer. 78 | */ 79 | static isLayer(svgElement) { 80 | return true; 81 | } 82 | 83 | /** Get the label of a layer represented by the given SVG group. 84 | * 85 | * If a group has been identified as a layer, this method will 86 | * return the name/title/label of this layer if it exists. 87 | * 88 | * @param {SVGGElement} svgElement - A group to check. 89 | * @returns {?string} - The label of the layer. 90 | */ 91 | static getLabel(svgElement) { 92 | return null; 93 | } 94 | } 95 | 96 | /** SVG document wrapper. */ 97 | export class SVGDocumentWrapper { 98 | 99 | /** Initialize a new wrapper for a given SVG root element. 100 | * 101 | * @param {SVGSVGElement} svgRoot - An SVG root element. 102 | */ 103 | constructor(svgRoot) { 104 | /** A serialized representation of the current SVG document. 105 | * 106 | * @type {string} 107 | */ 108 | this.asText = ""; 109 | 110 | /** The SVG handler class for the current SVG document. 111 | * 112 | * @type {Function} 113 | */ 114 | this.handler = DefaultSVGHandler; 115 | 116 | /** The current SVG root element. 117 | * 118 | * @type {SVGSVGElement} 119 | */ 120 | this.root = svgRoot; 121 | 122 | // Prevent event propagation on hyperlinks 123 | for (let link of this.root.getElementsByTagName("a")) { 124 | link.addEventListener("mousedown", evt => evt.stopPropagation(), false); 125 | } 126 | } 127 | 128 | /** Does the current root element belong to a valid SVG document? 129 | * 130 | * @readonly 131 | * @type {boolean} 132 | */ 133 | get isValidSVG() { 134 | return this.root instanceof SVGSVGElement; 135 | } 136 | 137 | /** Check whether an SVG element represents a layer. 138 | * 139 | * The given node is a valid layer if it has the following characteristics: 140 | * - it is an SVG group element, 141 | * - it has an ID that has not been met before, 142 | * - it is recognized as a layer by the current SVG handler. 143 | * 144 | * @param {Node} svgNode - An XML node to check. 145 | * @returns {boolean} - `true` if the given node represents a layer. 146 | */ 147 | isLayer(svgNode) { 148 | return svgNode instanceof SVGGElement && 149 | svgNode.hasAttribute("id") && 150 | this.handler.isLayer(svgNode); 151 | } 152 | 153 | /** Parse the given string into a new SVG document wrapper. 154 | * 155 | * This method will also apply several preprocessing operations, 156 | * some generic and some specific to a SVG handler. 157 | * 158 | * @param {string} data - A string containing a serialized SVG document. 159 | * @returns {module:svg/SVGDocumentWrapper.SVGDocumentWrapper} - A new SVG document wrapper. 160 | * 161 | * @see {@linkcode module:svg/SVGDocumentWrapper.DefaultSVGHandler.transform} 162 | */ 163 | static fromString(data) { 164 | const svgRoot = new DOMParser().parseFromString(data, "image/svg+xml").documentElement; 165 | const doc = new SVGDocumentWrapper(svgRoot); 166 | 167 | for (let name in handlers) { 168 | if (handlers[name].matches(svgRoot)) { 169 | console.log(`Using handler: ${name}`); 170 | doc.handler = handlers[name]; 171 | break; 172 | } 173 | } 174 | 175 | // Check that the root is an SVG element 176 | if (doc.isValidSVG) { 177 | // Apply handler-specific transformations 178 | doc.handler.transform(svgRoot); 179 | 180 | // Remove attributes that prevent correct rendering 181 | doc.removeViewbox(); 182 | 183 | // Remove any existing script inside the SVG DOM tree 184 | doc.removeScripts(); 185 | 186 | // Disable hyperlinks 187 | doc.disableHyperlinks(); 188 | 189 | // Fix elements from Adobe Illustrator. 190 | // We do not import AiHandler in this module to avoid a circular dependency. 191 | const AiHandler = handlers["Adobe Illustrator"]; 192 | if (doc.handler !== AiHandler) { 193 | AiHandler.transform(svgRoot); 194 | } 195 | 196 | // Wrap isolated elements into groups 197 | let svgWrapper = document.createElementNS(SVG_NS, "g"); 198 | 199 | // Get all child nodes of the SVG root. 200 | // Make a copy of svgRoot.childNodes before modifying the document. 201 | for (let svgNode of Array.from(svgRoot.childNodes)) { 202 | // Remove text nodes and comments 203 | if (svgNode.tagName === undefined) { 204 | svgRoot.removeChild(svgNode); 205 | } 206 | // Reorganize drawable SVG elements into top-level groups 207 | else if (DRAWABLE_TAGS.indexOf(svgNode.localName) >= 0) { 208 | // If the current node is not a layer, 209 | // add it to the current wrapper. 210 | if (!doc.isLayer(svgNode)) { 211 | svgWrapper.appendChild(svgNode); 212 | } 213 | // If the current node is a layer and the current 214 | // wrapper contains elements, insert the wrapper 215 | // into the document and create a new empty wrapper. 216 | else if (svgWrapper.firstChild) { 217 | svgRoot.insertBefore(svgWrapper, svgNode); 218 | svgWrapper = document.createElementNS(SVG_NS, "g"); 219 | } 220 | } 221 | } 222 | 223 | // If the current wrapper layer contains elements, 224 | // add it to the document. 225 | if (svgWrapper.firstChild) { 226 | svgRoot.appendChild(svgWrapper); 227 | } 228 | } 229 | 230 | doc.asText = new XMLSerializer().serializeToString(svgRoot); 231 | 232 | return doc; 233 | } 234 | 235 | /** Remove the `viewBox` attribute from the SVG root element. 236 | * 237 | * This attribute conflicts with the Sozi viewport. 238 | * This method also sets the dimensions of the SVG root to 100%. 239 | */ 240 | removeViewbox() { 241 | this.root.removeAttribute("viewBox"); 242 | this.root.style.width = this.root.style.height = "100%"; 243 | } 244 | 245 | /** Remove the scripts embedded in the SVG. 246 | * 247 | * The presentation editor operates on static SVG documents. 248 | * Third-party scripts are removed because they could interfere with the editor. 249 | * 250 | * Custom scripts can be added into the generated HTML via the 251 | * presentation editor. 252 | */ 253 | removeScripts() { 254 | // Make a copy of root.childNodes before modifying the document. 255 | const scripts = Array.from(this.root.getElementsByTagName("script")); 256 | for (let script of scripts) { 257 | script.parentNode.removeChild(script); 258 | } 259 | } 260 | 261 | /** Disable the hyperlinks inside the document. 262 | * 263 | * Hyperlinks are disabled in the editor only. 264 | * This operation does not affect the saved presentation. 265 | * 266 | * @param {boolean} styled - If `true`, disable the hand-shaped mouse cursor over links. 267 | */ 268 | disableHyperlinks(styled=false) { 269 | for (let link of this.root.getElementsByTagName("a")) { 270 | link.addEventListener("click", evt => evt.preventDefault(), false); 271 | if (styled) { 272 | link.style.cursor = "default"; 273 | } 274 | } 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /src/js/svg/index.js: -------------------------------------------------------------------------------- 1 | 2 | import "./AiHandler"; 3 | import "./InkscapeHandler"; 4 | import "./ImpressHandler"; 5 | -------------------------------------------------------------------------------- /src/js/view/Preview.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** @module */ 6 | 7 | const PREVIEW_MARGIN = 15; 8 | 9 | /** The preview area in the presentation editor. */ 10 | export class Preview { 11 | /** Initialize a new preview area. 12 | * 13 | * This method registers the event handlers for the preview area of the presentation editor. 14 | * 15 | * @param {HTMLElement} container - The HTML element that will contain this preview area. 16 | * @param {module:model/Presentation.Presentation} presentation - The current Sozi presentation. 17 | * @param {module:model/Selection.Selection} selection - The object that manages the frame and layer selection. 18 | * @param {module:player/Viewport.Viewport} viewport - The viewport where the presentation is displayed. 19 | * @param {module:Controller.Controller} controller - The controller that manages the current editor. 20 | * @param {module:player/PlayerController.PlayerController} playerController - The controller that manages the player user interactions. 21 | */ 22 | constructor(container, presentation, selection, viewport, controller, playerController) { 23 | /** The HTML element that will contain this preview area. 24 | * 25 | * @type {HTMLElement} 26 | */ 27 | this.container = container; 28 | 29 | /** The current Sozi presentation. 30 | * 31 | * @type {module:model/Presentation.Presentation} 32 | */ 33 | this.presentation = presentation; 34 | 35 | /** The object that manages the frame and layer selection. 36 | * 37 | * @type {module:model/Selection.Selection} 38 | */ 39 | this.selection = selection; 40 | 41 | /** The viewport where the presentation is displayed. 42 | * 43 | * @type {module:player/Viewport.Viewport} 44 | */ 45 | this.viewport = viewport; 46 | 47 | /** The controller that manages the current editor. 48 | * 49 | * @type {module:Controller.Controller} 50 | */ 51 | this.controller = controller; 52 | 53 | /** The controller that manages the player user interactions. 54 | * 55 | * @type {module:player/PlayerController.PlayerController} 56 | */ 57 | this.playerController = playerController; 58 | 59 | presentation.on("svgChange", () => this.onLoad()); 60 | window.addEventListener("resize", () => this.repaint()); 61 | playerController.on("mouseDown", () => document.activeElement.blur()); 62 | playerController.on("click", evt => this.onClick(evt)); 63 | playerController.on("localViewportChange", () => controller.updateCameraStates()); 64 | controller.on("repaint", () => this.repaint()); 65 | } 66 | 67 | /** Reset the preview area when a presentation is loaded or reloaded. 68 | * 69 | * @listens module:model/Presentation.svgChange 70 | */ 71 | onLoad() { 72 | // Set the window title to the presentation title 73 | document.querySelector("html head title").innerHTML = this.presentation.title; 74 | 75 | // Replace the content of the preview area with the SVG document 76 | while(this.container.hasChildNodes()) { 77 | this.container.removeChild(this.container.firstChild); 78 | } 79 | this.container.appendChild(this.presentation.document.root); 80 | 81 | this.viewport.onLoad(); 82 | this.playerController.onLoad(); 83 | this.presentation.setInitialCameraState(); 84 | 85 | this.container.addEventListener("mouseenter", () => this.onMouseEnter(), false); 86 | this.container.addEventListener("mouseleave", () => this.onMouseLeave(), false); 87 | } 88 | 89 | /** Refresh this preview area on resize and repaint events. 90 | * 91 | * This method will update the geometry of the preview area, 92 | * realign all cameras and repaint the viewport. 93 | * 94 | * @listens resize 95 | * @listens module:Controller.repaint 96 | * 97 | * @see {@linkcode module:player/Viewport.Viewport#repaint} 98 | */ 99 | repaint() { 100 | // this.container is assumed to have padding: 0 101 | const parentWidth = this.container.parentNode.clientWidth; 102 | const parentHeight = this.container.parentNode.clientHeight; 103 | 104 | const maxWidth = parentWidth - 2 * PREVIEW_MARGIN; 105 | const maxHeight = parentHeight - 2 * PREVIEW_MARGIN; 106 | 107 | const width = Math.min(maxWidth, maxHeight * this.presentation.aspectWidth / this.presentation.aspectHeight); 108 | const height = Math.min(maxHeight, maxWidth * this.presentation.aspectHeight / this.presentation.aspectWidth); 109 | 110 | this.container.style.left = (parentWidth - width) / 2 + "px"; 111 | this.container.style.top = (parentHeight - height) / 2 + "px"; 112 | this.container.style.width = width + "px"; 113 | this.container.style.height = height + "px"; 114 | 115 | if (this.selection.currentFrame) { 116 | this.viewport.setAtStates(this.selection.currentFrame.cameraStates); 117 | } 118 | 119 | if (this.viewport.ready) { 120 | this.viewport.repaint(); 121 | } 122 | } 123 | 124 | /** Choose an outline element on an Alt+click event in this preview area. 125 | * 126 | * @param {MouseEvent} evt - A DOM event. 127 | * 128 | * @listens click 129 | */ 130 | onClick(evt) { 131 | if (evt.button === 0 && evt.altKey) { 132 | const outlineElement = evt.target; 133 | if (outlineElement.hasAttribute("id") && outlineElement.getBBox) { 134 | this.controller.setOutlineElement(outlineElement); 135 | } 136 | } 137 | } 138 | 139 | /** When the mouse hovers the preview area, reveal the clipping rectangle. 140 | * 141 | * @listens mouseenter 142 | * 143 | * @see {@linkcode module:player/Camera.Camera#revealClipping} 144 | */ 145 | onMouseEnter() { 146 | for (let camera of this.viewport.cameras) { 147 | if (camera.selected) { 148 | camera.revealClipping(); 149 | } 150 | } 151 | this.viewport.showHiddenElements = true; 152 | this.viewport.repaint(); 153 | } 154 | 155 | /** When the mouse leaves the preview area, conceal the clipping rectangle. 156 | * 157 | * @listens mouseleave 158 | * 159 | * @see {@linkcode module:player/Camera.Camera#concealClipping} 160 | */ 161 | onMouseLeave() { 162 | for (let camera of this.viewport.cameras) { 163 | if (camera.selected) { 164 | camera.concealClipping(); 165 | } 166 | } 167 | this.viewport.showHiddenElements = false; 168 | this.viewport.repaint(); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/js/view/Toolbar.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** @module */ 6 | 7 | import {h} from "inferno-hyperscript"; 8 | import {VirtualDOMView} from "./VirtualDOMView"; 9 | import screenfull from "screenfull"; 10 | import pkg from "../../../package.json"; 11 | 12 | /** Toolbar in the presentation editor. 13 | * 14 | * @extends module:view/VirtualDOMView.VirtualDOMView 15 | * @todo Add documentation. 16 | */ 17 | export class Toolbar extends VirtualDOMView { 18 | 19 | /** Initialize a new toolbar view. 20 | * 21 | * @param {HTMLElement} container - The HTML element that will contain this preview area. 22 | * @param {module:view/Properties.Properties} properties - The properties view of the editor. 23 | * @param {module:model/Presentation.Presentation} presentation - The current Sozi presentation. 24 | * @param {module:player/Viewport.Viewport} viewport - The viewport where the presentation is displayed. 25 | * @param {module:Controller.Controller} controller - The controller that manages the current editor. 26 | */ 27 | constructor(container, properties, presentation, viewport, controller) { 28 | super(container, controller); 29 | 30 | /** The properties view of the editor. 31 | * 32 | * @type {module:view/Properties.Properties} 33 | */ 34 | this.properties = properties; 35 | 36 | /** The current Sozi presentation. 37 | * 38 | * @type {module:model/Presentation.Presentation} 39 | */ 40 | this.presentation = presentation; 41 | 42 | /** The viewport where the presentation is displayed. 43 | * 44 | * @type {module:player/Viewport.Viewport} 45 | */ 46 | this.viewport = viewport; 47 | 48 | screenfull.on("change", () => this.repaint()); 49 | properties.on("modeChange", () => this.repaint()); 50 | } 51 | 52 | /** @inheritdoc */ 53 | render() { 54 | const properties = this.properties; 55 | const controller = this.controller; 56 | const _ = controller.gettext; 57 | 58 | this.state["aspect-width"] = {value: this.presentation.aspectWidth}; 59 | this.state["aspect-height"] = {value: this.presentation.aspectHeight}; 60 | 61 | return h("div", [ 62 | h("span.group", [ 63 | _("Aspect ratio: "), 64 | h("input.aspect", { 65 | id: "field-aspect-width", 66 | type: "number", 67 | pattern: "\\d+", 68 | min: "1", 69 | step: "1", 70 | size: "3", 71 | onchange() { 72 | const width = parseInt(this.value); 73 | if (!width.isNaN) { 74 | controller.setAspectWidth(width); 75 | } 76 | } 77 | }), 78 | " : ", 79 | h("input.aspect", { 80 | id: "field-aspect-height", 81 | type: "number", 82 | pattern: "\\d+", 83 | min: "1", 84 | step: "1", 85 | size: "3", 86 | onchange() { 87 | const height = parseInt(this.value); 88 | if (!height.isNaN) { 89 | controller.setAspectHeight(height); 90 | } 91 | } 92 | }) 93 | ]), 94 | h("span.group.btn-group", [ 95 | h("button", { 96 | title: _("Move the selected layers (hold Alt to zoom, Shift to rotate)"), 97 | className: this.viewport.dragMode === "translate" ? "active" : "", 98 | onclick() { controller.setDragMode("translate"); } 99 | }, h("i.fa.fa-arrows")), 100 | h("button", { 101 | title: _("Zoom in/out on the selected layers (you can also hold the Alt key in Move mode)"), 102 | className: this.viewport.dragMode === "scale" ? "active" : "", 103 | onclick() { controller.setDragMode("scale"); } 104 | }, h("i.fa.fa-expand")), 105 | h("button", { 106 | title: _("Rotate the selected layers (you can also hold the Shift key in Move mode)"), 107 | className: this.viewport.dragMode === "rotate" ? "active" : "", 108 | onclick() { controller.setDragMode("rotate"); } 109 | }, h("i.fa.fa-rotate-left")), 110 | h("button", { 111 | title: _("Edit the clipping area"), 112 | className: this.viewport.dragMode === "clip" ? "active" : "", 113 | onclick() { controller.setDragMode("clip"); } 114 | }, h("i.fa.fa-crop")) 115 | ]), 116 | h("span.group.btn-group", [ 117 | h("button", { 118 | title: _("Undo"), 119 | disabled: controller.undoStack.length ? undefined : "disabled", 120 | onclick() { controller.undo(); } 121 | }, h("i.fa.fa-reply")), // "reply" icon preferred to the official "undo" icon 122 | h("button", { 123 | title: _("Redo"), 124 | disabled: controller.redoStack.length ? undefined : "disabled", 125 | onclick() { controller.redo(); } 126 | }, h("i.fa.fa-share")) // "share" icon preferred to the official "redo" icon 127 | ]), 128 | h("span.group", [ 129 | h("button", { 130 | title: screenfull.isFullscreen ? _("Disable full-screen mode") : _("Enable full-screen mode"), 131 | id: "btn-fullscreen", 132 | className: screenfull.isFullscreen ? "active" : undefined, 133 | disabled: !screenfull.isEnabled, 134 | onclick() { screenfull.toggle(document.documentElement); } 135 | }, h("i.fa.fa-desktop")) 136 | ]), 137 | h("span.group.btn-group", [ 138 | h("button", { 139 | title: _("Save the presentation"), 140 | disabled: controller.storage && controller.storage.htmlNeedsSaving ? undefined : "disabled", 141 | onclick() { controller.save(); } 142 | }, h("i.fa.fa-download")), // "download" icon preferred to the official "save" icon 143 | h("button", { 144 | title: _("Reload the SVG document"), 145 | onclick() { controller.reload(); } 146 | }, h("i.fa.fa-refresh")), 147 | // TODO disable the Export button if the feature is not available 148 | h("button", { 149 | title: _("Export the presentation"), 150 | className: properties.mode === "export" ? "active" : undefined, 151 | onclick() { properties.toggleMode("export"); } 152 | }, h("i.fa.fa-file")) // "file-export" is missing in Fork-Awesome 153 | ]), 154 | h("span.group.btn-group", [ 155 | h("button", { 156 | title: _("Preferences"), 157 | className: properties.mode === "preferences" ? "active" : undefined, 158 | onclick() { properties.toggleMode("preferences"); } 159 | }, h("i.fa.fa-sliders")), 160 | h("button", { 161 | title: _("Information"), 162 | onclick() { 163 | controller.hideNotification(); 164 | controller.info(`Sozi ${pkg.version}`, true); 165 | } 166 | }, h("i.fa.fa-info")) 167 | ]) 168 | ]); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/js/view/VirtualDOMView.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** @module */ 6 | 7 | import {render} from "inferno"; 8 | import {h} from "inferno-hyperscript"; 9 | import {EventEmitter} from "events"; 10 | 11 | /** Type for Virtual DOM nodes. 12 | * 13 | * @external VNode 14 | */ 15 | 16 | /** Base class for editor views using the virtual DOM. 17 | * 18 | * @extends EventEmitter 19 | */ 20 | export class VirtualDOMView extends EventEmitter { 21 | 22 | /** Initialize a new virtual DOM view. 23 | * 24 | * @param {HTMLElement} container - The HTML element that will contain this preview area. 25 | * @param {module:Controller.Controller} controller - The controller that manages the current editor. 26 | */ 27 | constructor(container, controller) { 28 | super(); 29 | 30 | /** The HTML element that will contain this preview area. 31 | * 32 | * @type {HTMLElement} 33 | */ 34 | this.container = container; 35 | 36 | /** The controller that manages the current editor. 37 | * 38 | * @type {module:Controller.Controller} 39 | */ 40 | this.controller = controller; 41 | 42 | /** Form field values that need to be set after rendering. 43 | * 44 | * @type {object} 45 | */ 46 | this.state = {}; 47 | 48 | const repaintHandler = () => this.repaint(); 49 | controller.on("repaint", repaintHandler); 50 | window.addEventListener("resize", repaintHandler); 51 | 52 | while (container.firstChild) { 53 | container.removeChild(container.firstChild); 54 | } 55 | } 56 | 57 | /** Repaint this view. 58 | * 59 | * This will render the current view using the result of {@linkcode module:view/VirtualDOMView.VirtualDOMView#render|render}. 60 | * 61 | * @listens resize 62 | * @listens module:Controller.repaint 63 | */ 64 | repaint() { 65 | render(this.render(), this.container, () => { 66 | for (let prop in this.state) { 67 | const elt = document.getElementById("field-" + prop); 68 | if (elt) { 69 | for (let attr in this.state[prop]) { 70 | elt[attr] = this.state[prop][attr]; 71 | } 72 | } 73 | } 74 | }); 75 | } 76 | 77 | /** Render this view as a virtual DOM tree. 78 | * 79 | * @returns {VNode} - A virtual DOM tree for this view. 80 | */ 81 | render() { 82 | return h("div"); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/js/view/languages.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** Internationalization support in the presentation editor. 6 | * 7 | * @module 8 | */ 9 | 10 | /** Returns the supported languages in the user interface of Sozi. 11 | * 12 | * @param {Function} _ - The `gettext` function. 13 | * @returns {object} - A dictionary that maps language tags to language names. 14 | */ 15 | export function getLanguages(_) { 16 | return { 17 | auto: _("System language"), 18 | ar: "العربية", 19 | bg: "български език", 20 | ca: "Català", 21 | cs: "Čeština", 22 | da: "Dansk", 23 | de: "Deutsch", 24 | el: "Ελληνικά", 25 | en: "English", 26 | eo: "Esperanto", 27 | es: "Español", 28 | et: "Eesti", 29 | fa: "فارسی", 30 | fi: "Suomi", 31 | fr: "Français", 32 | gl: "Galego", 33 | he: "עִבְרִית", 34 | hu: "Magyar", 35 | id: "Bahasa Indonesia", 36 | it: "Italiano", 37 | ja: "日本語", 38 | kab: "Taqbaylit", 39 | ko: "한국어", 40 | lt: "Lietuvių", 41 | ms: "Bahasa Melayu", 42 | nb: "Norsk (Bokmål)", 43 | nl: "Nederlands", 44 | nn: "Norsk (Nynorsk)", 45 | pl: "Polski", 46 | pt_BR: "Português (Brasil)", 47 | pt: "Português", 48 | ru: "Русский", 49 | sk: "Slovenčina", 50 | sv: "Svenska", 51 | tr: "Türkçe", 52 | zh_Hans: "简体中文", 53 | zh_Latn: "Hànyǔ Pīnyīn", 54 | zh_TW: "繁體字", 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /src/templates/player.html: -------------------------------------------------------------------------------- 1 | {# This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. #} 4 | 5 | {% raw %} 6 | 7 | 8 | 9 | 10 | 11 | {{ pres.title }} 12 | 128 | 129 | 130 | {{ svg }} 131 |
      1
      132 |
      141 |
      142 | 143 |
      144 | 145 | {% endraw %} 146 | 147 | {% raw %} 148 | 151 | {% endraw %} 152 | 153 | 154 | -------------------------------------------------------------------------------- /src/templates/presenter.html: -------------------------------------------------------------------------------- 1 | {# This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. #} 4 | 5 | {% raw %} 6 | 7 | 8 | 9 | 10 | 11 | {{ pres.title }} 12 | 82 | 83 | 84 |
      85 |
      86 | 87 |
      88 |
      89 | 90 | / 91 | 92 |
      93 |
      94 | 95 | 96 |
      97 |
      98 |
      99 |

      100 |
      101 |
      102 | {% endraw %} 103 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /tools/deps.sh: -------------------------------------------------------------------------------- 1 | # Install lddtree from package pax-utils 2 | lddtree $1 | grep "^ l" | cut -d ">" -f 2 | while read n; do dpkg-query -S $(readlink -f $n); done | sed 's/^\([^:]\+\):.*$/\1/' | sort | uniq 3 | --------------------------------------------------------------------------------