├── .eslintrc ├── .github ├── ISSUE_TEMPLATE.md ├── stale.yml └── workflows │ └── ci.yml ├── .gitignore ├── .node-version ├── .npmignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── action_text-trix ├── .gitignore ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── action_text-trix.gemspec ├── app │ └── assets │ │ ├── javascripts │ │ ├── .gitattributes │ │ └── trix.js │ │ └── stylesheets │ │ └── trix.css └── lib │ └── action_text │ ├── trix.rb │ └── trix │ ├── engine.rb │ └── version.rb ├── assets ├── index.html ├── test.html ├── trix.scss └── trix │ ├── images │ ├── README.md │ ├── attach.svg │ ├── bold.svg │ ├── bullets.svg │ ├── code.svg │ ├── heading_1.svg │ ├── italic.svg │ ├── link.svg │ ├── nesting_level_decrease.svg │ ├── nesting_level_increase.svg │ ├── numbers.svg │ ├── quote.svg │ ├── redo.svg │ ├── remove.svg │ ├── strike.svg │ ├── trash.svg │ └── undo.svg │ └── stylesheets │ ├── attachments.scss │ ├── content.scss │ ├── editor.scss │ ├── icons.scss │ ├── media-queries.scss │ ├── selection.scss │ └── toolbar.scss ├── babel.config.json ├── bin ├── ci ├── sass-build └── setup ├── karma.conf.js ├── package.json ├── rollup.config.js ├── src ├── inspector │ ├── control_element.js │ ├── debugger.js │ ├── element.js │ ├── global.js │ ├── inspector.js │ ├── templates.js │ ├── templates │ │ ├── debug.js │ │ ├── document.js │ │ ├── performance.js │ │ ├── render.js │ │ ├── selection.js │ │ └── undo.js │ ├── view.js │ ├── views │ │ ├── debug_view.js │ │ ├── document_view.js │ │ ├── performance_view.js │ │ ├── render_view.js │ │ ├── selection_view.js │ │ └── undo_view.js │ ├── watchdog.js │ └── watchdog │ │ ├── deserializer.js │ │ ├── player.js │ │ ├── player_controller.js │ │ ├── player_element.js │ │ ├── player_view.js │ │ ├── recorder.js │ │ ├── recording.js │ │ └── serializer.js ├── test │ ├── system.js │ ├── system │ │ ├── accessibility_test.js │ │ ├── attachment_caption_test.js │ │ ├── attachment_gallery_test.js │ │ ├── attachment_test.js │ │ ├── basic_input_test.js │ │ ├── block_formatting_test.js │ │ ├── caching_test.js │ │ ├── canceled_input_test.js │ │ ├── composition_input_test.js │ │ ├── cursor_movement_test.js │ │ ├── custom_element_test.js │ │ ├── html_loading_test.js │ │ ├── html_reparsing_test.js │ │ ├── html_replacement_test.js │ │ ├── installation_process_test.js │ │ ├── level_2_input_test.js │ │ ├── list_formatting_test.js │ │ ├── morphing_test.js │ │ ├── mutation_input_test.js │ │ ├── pasting_test.js │ │ ├── text_formatting_test.js │ │ └── undo_test.js │ ├── test.js │ ├── test_helper.js │ ├── test_helpers │ │ ├── assertions.js │ │ ├── editor_helpers.js │ │ ├── event_helpers.js │ │ ├── fixtures │ │ │ ├── editor_default_aria_label.js │ │ │ ├── editor_empty.js │ │ │ ├── editor_html.js │ │ │ ├── editor_in_table.js │ │ │ ├── editor_with_block_styles.js │ │ │ ├── editor_with_bold_styles.js │ │ │ ├── editor_with_image.js │ │ │ ├── editor_with_labels.js │ │ │ ├── editor_with_styled_content.js │ │ │ ├── editor_with_toolbar_and_input.js │ │ │ ├── editors_with_forms.js │ │ │ ├── fixtures.js │ │ │ ├── logo.png │ │ │ └── test_image_url.js │ │ ├── functions.js │ │ ├── input_helpers.js │ │ ├── selection_helpers.js │ │ ├── test_helpers.js │ │ ├── test_stubs.js │ │ ├── timing_helpers.js │ │ └── toolbar_helpers.js │ ├── unit.js │ └── unit │ │ ├── attachment_test.js │ │ ├── bidi_test.js │ │ ├── block_test.js │ │ ├── composition_test.js │ │ ├── document_test.js │ │ ├── document_view_test.js │ │ ├── helpers │ │ └── custom_elements_test.js │ │ ├── html_parser_test.js │ │ ├── html_sanitizer_test.js │ │ ├── location_mapper_test.js │ │ ├── mutation_observer_test.js │ │ ├── serialization_test.js │ │ ├── string_change_summary_test.js │ │ └── text_test.js └── trix │ ├── config │ ├── attachments.js │ ├── block_attributes.js │ ├── browser.js │ ├── css.js │ ├── dompurify.js │ ├── file_size_formatting.js │ ├── index.js │ ├── input.js │ ├── key_names.js │ ├── lang.js │ ├── parser.js │ ├── text_attributes.js │ ├── toolbar.js │ └── undo.js │ ├── constants.js │ ├── controllers │ ├── attachment_editor_controller.js │ ├── composition_controller.js │ ├── controller.js │ ├── editor_controller.js │ ├── index.js │ ├── input_controller.js │ ├── level_0_input_controller.js │ ├── level_2_input_controller.js │ └── toolbar_controller.js │ ├── core │ ├── basic_object.js │ ├── collections │ │ ├── element_store.js │ │ ├── hash.js │ │ ├── index.js │ │ ├── object_group.js │ │ └── object_map.js │ ├── helpers │ │ ├── arrays.js │ │ ├── bidi.js │ │ ├── config.js │ │ ├── custom_elements.js │ │ ├── dom.js │ │ ├── events.js │ │ ├── extend.js │ │ ├── functions.js │ │ ├── global.js │ │ ├── index.js │ │ ├── objects.js │ │ ├── ranges.js │ │ ├── selection.js │ │ └── strings.js │ ├── index.js │ ├── object.js │ ├── serialization.js │ ├── utilities.js │ └── utilities │ │ ├── index.js │ │ ├── operation.js │ │ └── utf16_string.js │ ├── elements │ ├── index.js │ ├── trix_editor_element.js │ └── trix_toolbar_element.js │ ├── filters │ ├── attachment_gallery_filter.js │ ├── filter.js │ └── index.js │ ├── models │ ├── attachment.js │ ├── attachment_manager.js │ ├── attachment_piece.js │ ├── block.js │ ├── composition.js │ ├── document.js │ ├── editor.js │ ├── flaky_android_keyboard_detector.js │ ├── html_parser.js │ ├── html_sanitizer.js │ ├── index.js │ ├── line_break_insertion.js │ ├── location_mapper.js │ ├── managed_attachment.js │ ├── piece.js │ ├── point_mapper.js │ ├── selection_manager.js │ ├── splittable_list.js │ ├── string_piece.js │ ├── text.js │ └── undo_manager.js │ ├── observers │ ├── index.js │ ├── mutation_observer.js │ └── selection_change_observer.js │ ├── operations │ ├── file_verification_operation.js │ ├── image_preload_operation.js │ └── index.js │ ├── trix.js │ └── views │ ├── attachment_view.js │ ├── block_view.js │ ├── document_view.js │ ├── index.js │ ├── object_view.js │ ├── piece_view.js │ ├── previewable_attachment_view.js │ └── text_view.js └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "eslint:recommended", 4 | "rules": { 5 | "array-bracket-spacing": ["error", "always"], 6 | "block-spacing": ["error", "always"], 7 | "camelcase": ["error"], 8 | "comma-spacing": ["error"], 9 | "curly": ["error", "multi-line"], 10 | "dot-notation": ["error"], 11 | "eol-last": ["error"], 12 | "getter-return": ["error"], 13 | "id-length": ["error", { "properties": "never", "exceptions": ["_", "i", "j", "n"] }], 14 | "keyword-spacing": ["error"], 15 | "no-extra-parens": ["error"], 16 | "no-multi-spaces": ["error", { "exceptions": { "VariableDeclarator": true } }], 17 | "no-multiple-empty-lines": ["error", { "max": 2 }], 18 | "no-restricted-globals": ["error", "event"], 19 | "no-trailing-spaces": ["error"], 20 | "no-unused-vars": ["error", { "vars": "all", "args": "none" }], 21 | "no-var": ["error"], 22 | "object-curly-spacing": ["error", "always"], 23 | "prefer-const": ["error"], 24 | "quotes": ["error", "double"], 25 | "semi": ["error", "never"], 26 | "sort-imports": ["error", { "ignoreDeclarationSort": true }] 27 | }, 28 | "ignorePatterns": ["dist/**/*.js", "**/vendor/**/*.js", "action_text-trix/**/*.js"], 29 | "globals": { 30 | "after": true, 31 | "getComposition": true, 32 | "getDocument": true, 33 | "getEditor": true, 34 | "getEditorController": true, 35 | "getEditorElement": true, 36 | "getSelectionManager": true, 37 | "getToolbarElement": true, 38 | "QUnit": true, 39 | "rangy": true, 40 | "Trix": true 41 | }, 42 | "env": { 43 | "browser": true, 44 | "node": true, 45 | "es6": true 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Describe the bug or issue here… 2 | 3 | ##### Steps to Reproduce 4 | 5 | 1. 6 | 2. 7 | 3. 8 | 9 | ##### Details 10 | 11 | * Trix version: 12 | * Browser name and version: 13 | * Operating system: 14 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 90 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - bug 8 | - enhancement 9 | - pinned 10 | - security 11 | - WIP 12 | # Label to use when marking an issue as stale 13 | staleLabel: stale 14 | # Comment to post when marking an issue as stale. Set to `false` to disable 15 | markComment: > 16 | This issue has been automatically marked as stale after 90 days of inactivity. 17 | It will be closed if no further activity occurs. 18 | pulls: 19 | markComment: > 20 | This pull request has been automatically marked as stale after 90 days of inactivity. 21 | It will be closed if no further activity occurs. 22 | # Comment to post when closing a stale issue. Set to `false` to disable 23 | closeComment: false 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | concurrency: 4 | group: "${{github.workflow}}-${{github.ref}}" 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: [ main ] 10 | pull_request: 11 | types: [opened, synchronize] 12 | branches: [ '*' ] 13 | 14 | jobs: 15 | build: 16 | name: Browser tests 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-node@v3 21 | with: 22 | node-version: 16 23 | cache: "yarn" 24 | - name: Install Dependencies 25 | run: yarn install --frozen-lockfile 26 | - run: bin/ci 27 | rails-tests: 28 | name: Downstream Rails integration tests 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: actions/setup-node@v3 33 | with: 34 | node-version: 16 35 | cache: "yarn" 36 | - uses: ruby/setup-ruby@v1 37 | with: 38 | ruby-version: "3.4" 39 | - name: Install Dependencies 40 | run: yarn install --frozen-lockfile 41 | - name: Packaging 42 | run: yarn build 43 | - name: Clone Rails 44 | run: git clone --depth=1 https://github.com/rails/rails 45 | - name: Configure Rails 46 | run: | 47 | sudo apt install libvips-tools 48 | cd rails 49 | yarn install --frozen-lockfile 50 | bundle add action_text-trix --path ".." 51 | bundle show --paths action_text-trix 52 | - name: Action Text tests 53 | run: | 54 | cd rails/actiontext 55 | bundle exec rake test test:system 56 | 57 | env: 58 | SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} 59 | SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | yarn-error.log 2 | package-lock.json 3 | /dist 4 | /node_modules 5 | /tmp 6 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 16.14.0 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /node_modules 3 | /.github 4 | /bin 5 | /assets 6 | yarn-error.log 7 | yarn.lock 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 37signals, LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /action_text-trix/.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | pkg 3 | -------------------------------------------------------------------------------- /action_text-trix/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in trix.gemspec. 4 | gemspec 5 | -------------------------------------------------------------------------------- /action_text-trix/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 37signals, LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /action_text-trix/README.md: -------------------------------------------------------------------------------- 1 | ## Building the Trix Ruby gem 2 | 3 | 1. `cd action_text-trix` 4 | 2. `bundle exec rake sync` (updates files which must be committed to git) 5 | 3. `bundle exec rake build` 6 | 4. `gem push pkg/*.gem` 7 | -------------------------------------------------------------------------------- /action_text-trix/Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/clean" 3 | 4 | task :sync do 5 | require "json" 6 | 7 | FileUtils.cp File.expand_path("../LICENSE", __dir__), __dir__, verbose: true 8 | FileUtils.cp File.expand_path("../dist/trix.umd.js", __dir__), File.expand_path("app/assets/javascripts/trix.js", __dir__), verbose: true 9 | FileUtils.cp File.expand_path("../dist/trix.css", __dir__), File.expand_path("app/assets/stylesheets/trix.css", __dir__), verbose: true 10 | 11 | package_json = JSON.load(File.read(File.join(__dir__, "../package.json"))) 12 | version = package_json["version"] 13 | File.write(File.join(__dir__, "lib", "action_text", "trix", "version.rb"), <<~RUBY) 14 | module Trix 15 | VERSION = "#{version}" 16 | end 17 | RUBY 18 | puts "Updated gem version to #{version}" 19 | end 20 | 21 | CLEAN.add "pkg" 22 | CLOBBER.add "app/assets/javascripts/trix.js", "app/assets/stylesheets/trix.css" 23 | -------------------------------------------------------------------------------- /action_text-trix/action_text-trix.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/action_text/trix/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "action_text-trix" 5 | spec.version = Trix::VERSION 6 | spec.authors = "37signals, LLC" 7 | spec.summary = "A rich text editor for everyday writing" 8 | spec.license = "MIT" 9 | 10 | spec.homepage = "https://github.com/basecamp/trix" 11 | spec.metadata["homepage_uri"] = spec.homepage 12 | spec.metadata["source_code_uri"] = spec.homepage 13 | spec.metadata["changelog_uri"] = "#{spec.homepage}/releases" 14 | 15 | spec.metadata["rubygems_mfa_required"] = "true" 16 | 17 | spec.files = [ 18 | "LICENSE", 19 | "app/assets/javascripts/trix.js", 20 | "app/assets/stylesheets/trix.css", 21 | "lib/action_text/trix.rb", 22 | "lib/action_text/trix/engine.rb", 23 | "lib/action_text/trix/version.rb" 24 | ] 25 | 26 | spec.add_dependency "railties" 27 | end 28 | -------------------------------------------------------------------------------- /action_text-trix/app/assets/javascripts/.gitattributes: -------------------------------------------------------------------------------- 1 | trix.js linguist-vendored -whitespace 2 | -------------------------------------------------------------------------------- /action_text-trix/lib/action_text/trix.rb: -------------------------------------------------------------------------------- 1 | require_relative "trix/version" 2 | require_relative "trix/engine" 3 | -------------------------------------------------------------------------------- /action_text-trix/lib/action_text/trix/engine.rb: -------------------------------------------------------------------------------- 1 | module Trix 2 | class Engine < ::Rails::Engine 3 | initializer "trix.asset" do |app| 4 | if app.config.respond_to?(:assets) 5 | app.config.assets.precompile += %w[ trix.js trix.css ] 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /action_text-trix/lib/action_text/trix/version.rb: -------------------------------------------------------------------------------- 1 | module Trix 2 | VERSION = "2.1.15" 3 | end 4 | -------------------------------------------------------------------------------- /assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Trix 6 | 7 | 8 | 9 | 10 | 38 | 39 | 40 | 76 | 77 | 78 |
79 | 80 |
81 | Output 82 | 83 |
84 |
85 | 86 | 87 | -------------------------------------------------------------------------------- /assets/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Test Suite 4 | 5 | 6 | 7 | 8 |
9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /assets/trix.scss: -------------------------------------------------------------------------------- 1 | @import "trix/stylesheets/editor"; 2 | @import "trix/stylesheets/toolbar"; 3 | @import "trix/stylesheets/attachments"; 4 | @import "trix/stylesheets/content"; 5 | -------------------------------------------------------------------------------- /assets/trix/images/README.md: -------------------------------------------------------------------------------- 1 | # Trix Icons 2 | 3 | Trix's toolbar uses [Material Design Icons by Google][1], which are licensed under the [Creative Commons Attribution 4.0 International License (CC-BY 4.0)][2]. Some icons have been modified. 4 | 5 | [1]: https://github.com/google/material-design-icons 6 | [2]: https://github.com/google/material-design-icons/blob/master/LICENSE 7 | -------------------------------------------------------------------------------- /assets/trix/images/attach.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/trix/images/bold.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/trix/images/bullets.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/trix/images/code.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/trix/images/heading_1.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/trix/images/italic.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/trix/images/link.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/trix/images/nesting_level_decrease.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/trix/images/nesting_level_increase.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/trix/images/numbers.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/trix/images/quote.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/trix/images/redo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/trix/images/remove.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/trix/images/strike.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/trix/images/trash.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/trix/images/undo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/trix/stylesheets/content.scss: -------------------------------------------------------------------------------- 1 | $quote-border-width: 0.3em; 2 | $quote-margin-start: 0.3em; 3 | $quote-padding-start: 0.6em; 4 | 5 | .trix-content { 6 | line-height: 1.5; 7 | overflow-wrap: break-word; 8 | word-break: break-word; 9 | 10 | * { 11 | box-sizing: border-box; 12 | margin: 0; 13 | padding: 0; 14 | } 15 | 16 | h1 { 17 | font-size: 1.2em; 18 | line-height: 1.2; 19 | } 20 | 21 | blockquote { 22 | border: 0 solid #ccc; 23 | border-left-width: $quote-border-width; 24 | margin-left: $quote-margin-start; 25 | padding-left: $quote-padding-start; 26 | } 27 | 28 | [dir=rtl] blockquote, 29 | blockquote[dir=rtl] { 30 | border-width: 0; 31 | border-right-width: $quote-border-width; 32 | margin-right: $quote-margin-start; 33 | padding-right: $quote-padding-start; 34 | } 35 | 36 | li { 37 | margin-left: 1em; 38 | } 39 | 40 | [dir=rtl] li { 41 | margin-right: 1em; 42 | } 43 | 44 | pre { 45 | display: inline-block; 46 | width: 100%; 47 | vertical-align: top; 48 | font-family: monospace; 49 | font-size: 0.9em; 50 | padding: 0.5em; 51 | white-space: pre; 52 | background-color: #eee; 53 | overflow-x: auto; 54 | } 55 | 56 | img { 57 | max-width: 100%; 58 | height: auto; 59 | } 60 | 61 | .attachment { 62 | display: inline-block; 63 | position: relative; 64 | max-width: 100%; 65 | 66 | a { 67 | color: inherit; 68 | text-decoration: none; 69 | 70 | &:hover, 71 | &:visited:hover { 72 | color: inherit; 73 | } 74 | } 75 | } 76 | 77 | .attachment__caption { 78 | text-align: center; 79 | 80 | .attachment__name + .attachment__size { 81 | &::before { 82 | content: ' \2022 '; 83 | } 84 | } 85 | } 86 | 87 | .attachment--preview { 88 | width: 100%; 89 | text-align: center; 90 | 91 | .attachment__caption { 92 | color: #666; 93 | font-size: 0.9em; 94 | line-height: 1.2; 95 | } 96 | } 97 | 98 | .attachment--file { 99 | color: #333; 100 | line-height: 1; 101 | margin: 0 2px 2px 2px; 102 | padding: 0.4em 1em; 103 | border: 1px solid #bbb; 104 | border-radius: 5px; 105 | } 106 | 107 | .attachment-gallery { 108 | display: flex; 109 | flex-wrap: wrap; 110 | position: relative; 111 | 112 | .attachment { 113 | flex: 1 0 33%; 114 | padding: 0 0.5em; 115 | max-width: 33%; 116 | } 117 | 118 | &.attachment-gallery--2, 119 | &.attachment-gallery--4 { 120 | .attachment { 121 | flex-basis: 50%; 122 | max-width: 50%; 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /assets/trix/stylesheets/editor.scss: -------------------------------------------------------------------------------- 1 | trix-editor { 2 | border: 1px solid #bbb; 3 | border-radius: 3px; 4 | margin: 0; 5 | padding: 0.4em 0.6em; 6 | min-height: 5em; 7 | outline: none; 8 | } 9 | -------------------------------------------------------------------------------- /assets/trix/stylesheets/icons.scss: -------------------------------------------------------------------------------- 1 | $icon-attach: svg('trix/images/attach.svg'); 2 | $icon-bold: svg('trix/images/bold.svg'); 3 | $icon-bullets: svg('trix/images/bullets.svg'); 4 | $icon-code: svg('trix/images/code.svg'); 5 | $icon-heading-1: svg('trix/images/heading_1.svg'); 6 | $icon-italic: svg('trix/images/italic.svg'); 7 | $icon-link: svg('trix/images/link.svg'); 8 | $icon-nesting-level-decrease: svg('trix/images/nesting_level_decrease.svg'); 9 | $icon-nesting-level-increase: svg('trix/images/nesting_level_increase.svg'); 10 | $icon-numbers: svg('trix/images/numbers.svg'); 11 | $icon-quote: svg('trix/images/quote.svg'); 12 | $icon-redo: svg('trix/images/redo.svg'); 13 | $icon-remove: svg('trix/images/remove.svg'); 14 | $icon-strike: svg('trix/images/strike.svg'); 15 | $icon-undo: svg('trix/images/undo.svg'); 16 | -------------------------------------------------------------------------------- /assets/trix/stylesheets/media-queries.scss: -------------------------------------------------------------------------------- 1 | $phone-width: 768px; 2 | 3 | @mixin phone { 4 | @media (max-width: #{$phone-width}) { 5 | @content; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /assets/trix/stylesheets/selection.scss: -------------------------------------------------------------------------------- 1 | %disable-selection { 2 | -webkit-user-select: none; 3 | -moz-user-select: none; 4 | -ms-user-select: none; 5 | user-select: none; 6 | } 7 | 8 | %invisible-selection { 9 | &::-moz-selection { background: none; } 10 | &::selection { background: none; } 11 | } 12 | 13 | %visible-selection { 14 | &::-moz-selection { background: highlight; } 15 | &::selection { background: highlight; } 16 | } 17 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", 4 | { 5 | "targets": { 6 | "chrome": "80", 7 | "safari": "12.1", 8 | "edge": "80", 9 | "firefox": "88" 10 | } 11 | } 12 | ] 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /bin/ci: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | if [ -n "$CI" ]; then 5 | echo "GITHUB_WORKFLOW: $GITHUB_WORKFLOW" 6 | echo "GITHUB_RUN_NUMBER: $GITHUB_RUN_NUMBER" 7 | echo "GITHUB_RUN_ID: $GITHUB_RUN_ID" 8 | echo "GITHUB_ACTOR: $GITHUB_ACTOR" 9 | echo "GITHUB_EVENT_NAME: $GITHUB_EVENT_NAME" 10 | echo "GITHUB_SHA: $GITHUB_SHA" 11 | echo "GITHUB_REF: $GITHUB_REF" 12 | echo "GITHUB_HEAD_REF: $GITHUB_HEAD_REF" 13 | echo "GITHUB_BASE_REF: $GITHUB_BASE_REF" 14 | fi 15 | 16 | yarn test 17 | -------------------------------------------------------------------------------- /bin/sass-build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const path = require("path") 4 | const fs = require("fs") 5 | const sass = require("sass") 6 | const { optimize } = require("svgo") 7 | const chokidar = require("chokidar") 8 | 9 | const args = process.argv.slice(2) 10 | if (args.length < 2) { 11 | console.error("Usage: bin/sass-build (--watch)") 12 | process.exit(1) 13 | } 14 | const inputFile = path.resolve(args[0]) 15 | const outputFile = path.resolve(args[1]) 16 | const watchMode = args.includes("--watch") 17 | 18 | const basePath = path.dirname(inputFile) 19 | 20 | const functions = { 21 | "svg($file)": (args) => { 22 | const fileName = args[0].assertString().text 23 | const filePath = path.resolve(basePath, fileName) 24 | 25 | let svgContent = fs.readFileSync(filePath, "utf8") 26 | svgContent = optimize(svgContent, { multipass: true, datauri: "enc" }) 27 | 28 | return new sass.SassString(`url("${svgContent.data}")`, { quotes: false }) 29 | } 30 | } 31 | 32 | function compile() { 33 | try { 34 | const result = sass.compile(inputFile, { functions }) 35 | 36 | fs.writeFileSync(outputFile, result.css, "utf8") 37 | } catch (error) { 38 | console.error("Error compiling SCSS:", error.message) 39 | } 40 | } 41 | compile() 42 | 43 | if (watchMode) { 44 | console.log(`Watching for SASS file changes under ${basePath}...`) 45 | chokidar.watch(basePath).on("change", (filePath) => { 46 | if (!filePath.endsWith(".scss")) return 47 | console.log(`[${new Date().toLocaleTimeString()}] ${filePath} changed. Recompiling...`) 48 | compile() 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eo pipefail 3 | 4 | # Use binstubs. Work from the root dir. 5 | app_root="$( cd "$(dirname "$0")/.."; pwd )" 6 | 7 | # Prefer bin/ executables 8 | export PATH="$app_root/bin:$PATH" 9 | 10 | if [ "$1" = "-v" ]; then 11 | exec 3>&1 12 | else 13 | exec 3>/dev/null 14 | exec 4>&1 15 | trap 'echo "Setup failed - run \`bin/setup -v\` to see the error output" >&4' ERR 16 | fi 17 | 18 | brew_install_missing() { 19 | if which brew > /dev/null; then 20 | if ! which "$1" > /dev/null; then 21 | echo " -- Installing Homebrew package: $@" 22 | brew reinstall "$@" 23 | fi 24 | else 25 | return 1 26 | fi 27 | } 28 | 29 | abort() { 30 | echo "$@" 31 | return 2 32 | } 33 | 34 | echo "--- Installing Ruby gems" 35 | { 36 | if which rbenv > /dev/null; then 37 | rbenv install --skip-existing 38 | else 39 | if ! which ruby > /dev/null; then 40 | brew_install_missing ruby || abort "Can't find or install Ruby. Install it from https://www.ruby-lang.org or with https://github.com/rbenv/rbenv" 41 | fi 42 | fi 43 | gem list -i bundler >/dev/null 2>&1 || gem install bundler 44 | bundle check || bundle install 45 | } >&3 2>&1 46 | 47 | echo "--- Installing npm modules" 48 | { 49 | if ! which npm > /dev/null; then 50 | brew_install_missing "npm" || abort "Can't find or install npm. Install it from https://nodejs.org" 51 | fi 52 | npm install 53 | } >&3 2>&1 54 | 55 | if [ -d "$HOME/.pow" ]; then 56 | echo "--- Setting up Pow" 57 | { ln -nfs "$app_root" "$HOME/.pow/trix" 58 | mkdir -p tmp 59 | touch tmp/restart.txt 60 | } >&3 2>&1 61 | fi 62 | 63 | echo 64 | echo "Done!" 65 | if [ -L "$HOME/.pow/trix" ]; then 66 | echo " * Open http://trix.dev to develop in-browser" 67 | else 68 | echo " * Run \`bin/rackup\` to develop in-browser" 69 | fi 70 | echo " * Run \`bin/blade build\` to build Trix" 71 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | browsers: [ "ChromeHeadless" ], 3 | frameworks: [ "qunit" ], 4 | files: [ 5 | { pattern: "dist/test.js", watched: false }, 6 | { pattern: "src/test_helpers/fixtures/*.png", watched: false, included: false, served: true } 7 | ], 8 | proxies: { 9 | "/test_helpers/fixtures/": "/base/src/test_helpers/fixtures/" 10 | }, 11 | client: { 12 | clearContext: false, 13 | qunit: { 14 | showUI: true 15 | } 16 | }, 17 | 18 | hostname: "0.0.0.0", 19 | 20 | singleRun: true, 21 | autoWatch: false, 22 | 23 | concurrency: 4, 24 | captureTimeout: 240000, 25 | browserDisconnectTimeout: 240000, 26 | browserDisconnectTolerance: 3, 27 | browserNoActivityTimeout: 300000, 28 | } 29 | 30 | /* eslint camelcase: "off", */ 31 | 32 | if (process.env.SAUCE_ACCESS_KEY) { 33 | config.customLaunchers = { 34 | sl_chrome_latest: { 35 | base: "SauceLabs", 36 | browserName: "chrome", 37 | version: "latest" 38 | }, 39 | sl_chrome_latest_i8n: { 40 | base: "SauceLabs", 41 | browserName: "chrome", 42 | version: "latest", 43 | chromeOptions: { 44 | args: [ "--lang=tr" ] 45 | } 46 | }, 47 | // Context: 48 | // https://github.com/karma-runner/karma-sauce-launcher/issues/275 49 | // https://saucelabs.com/blog/update-firefox-tests-before-oct-4-2022 50 | sl_firefox_latest: { 51 | base: "SauceLabs", 52 | browserName: "firefox", 53 | browserVersion: "latest", 54 | "moz:debuggerAddress": true 55 | }, 56 | sl_edge_latest: { 57 | base: "SauceLabs", 58 | browserName: "microsoftedge", 59 | platform: "Windows 10", 60 | version: "latest" 61 | }, 62 | sl_android_9: { 63 | base: "SauceLabs", 64 | browserName: "chrome", 65 | platform: "android", 66 | device: "Android GoogleAPI Emulator", 67 | version: "9.0" 68 | }, 69 | sl_android_latest: { 70 | base: "SauceLabs", 71 | browserName: "chrome", 72 | platform: "android", 73 | device: "Android GoogleAPI Emulator", 74 | version: "12.0" 75 | } 76 | } 77 | 78 | config.browsers = Object.keys(config.customLaunchers) 79 | config.reporters = [ "dots", "saucelabs" ] 80 | 81 | config.sauceLabs = { 82 | testName: "Trix", 83 | retryLimit: 3, 84 | idleTimeout: 600, 85 | commandTimeout: 600, 86 | maxDuration: 900, 87 | build: buildId(), 88 | } 89 | } 90 | 91 | function buildId() { 92 | const { GITHUB_WORKFLOW, GITHUB_RUN_NUMBER, GITHUB_RUN_ID } = process.env 93 | return GITHUB_WORKFLOW && GITHUB_RUN_NUMBER && GITHUB_RUN_ID 94 | ? `${GITHUB_WORKFLOW} #${GITHUB_RUN_NUMBER} (${GITHUB_RUN_ID})` 95 | : "" 96 | } 97 | 98 | module.exports = function(karmaConfig) { 99 | karmaConfig.set(config) 100 | } 101 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trix", 3 | "version": "2.1.15", 4 | "description": "A rich text editor for everyday writing", 5 | "main": "dist/trix.umd.min.js", 6 | "module": "dist/trix.esm.min.js", 7 | "style": "dist/trix.css", 8 | "files": [ 9 | "dist/*.css", 10 | "dist/*.js", 11 | "dist/*.map", 12 | "src/{inspector,trix}/*.js" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/basecamp/trix.git" 17 | }, 18 | "keywords": [ 19 | "rich text", 20 | "wysiwyg", 21 | "editor" 22 | ], 23 | "author": "37signals, LLC", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/basecamp/trix/issues" 27 | }, 28 | "homepage": "https://trix-editor.org/", 29 | "devDependencies": { 30 | "@babel/core": "^7.16.0", 31 | "@babel/preset-env": "^7.16.4", 32 | "@rollup/plugin-babel": "^5.3.0", 33 | "@rollup/plugin-commonjs": "^22.0.2", 34 | "@rollup/plugin-json": "^4.1.0", 35 | "@rollup/plugin-node-resolve": "^13.3.0", 36 | "@web/dev-server": "^0.1.34", 37 | "babel-eslint": "^10.1.0", 38 | "chokidar": "^4.0.2", 39 | "concurrently": "^7.4.0", 40 | "eslint": "^7.32.0", 41 | "esm": "^3.2.25", 42 | "karma": "6.4.1", 43 | "karma-chrome-launcher": "3.2.0", 44 | "karma-qunit": "^4.1.2", 45 | "karma-sauce-launcher": "^4.3.6", 46 | "qunit": "2.19.1", 47 | "rangy": "^1.3.0", 48 | "rollup": "^2.56.3", 49 | "rollup-plugin-includepaths": "^0.2.4", 50 | "rollup-plugin-terser": "^7.0.2", 51 | "sass": "^1.83.0", 52 | "svgo": "^2.8.0", 53 | "webdriverio": "^7.19.5" 54 | }, 55 | "resolutions": { 56 | "webdriverio": "^7.19.5" 57 | }, 58 | "scripts": { 59 | "build-css": "bin/sass-build assets/trix.scss dist/trix.css", 60 | "build-js": "rollup -c", 61 | "build-assets": "cp -f assets/*.html dist/", 62 | "build-ruby": "rake -C action_text-trix sync", 63 | "build": "yarn run build-js && yarn run build-css && yarn run build-assets && yarn run build-ruby", 64 | "watch": "rollup -c -w", 65 | "lint": "eslint .", 66 | "pretest": "yarn run lint && yarn run build", 67 | "test": "karma start", 68 | "prerelease": "yarn version && yarn test", 69 | "release-npm": "npm adduser && npm publish", 70 | "release-ruby": "rake -C action_text-trix release", 71 | "release": "yarn run release-npm && yarn run release-ruby", 72 | "postrelease": "git push && git push --tags", 73 | "dev": "web-dev-server --app-index index.html --root-dir dist --node-resolve --open", 74 | "start": "yarn build-assets && concurrently --kill-others --names js,css,dev-server 'yarn watch' 'yarn build-css --watch' 'yarn dev'" 75 | }, 76 | "dependencies": { 77 | "dompurify": "^3.2.5" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import json from "@rollup/plugin-json" 2 | import includePaths from "rollup-plugin-includepaths" 3 | import commonjs from "@rollup/plugin-commonjs" 4 | import { babel } from "@rollup/plugin-babel" 5 | import nodeResolve from "@rollup/plugin-node-resolve" 6 | import { terser } from "rollup-plugin-terser" 7 | 8 | import { version } from "./package.json" 9 | 10 | const year = new Date().getFullYear() 11 | const banner = `/*\nTrix ${version}\nCopyright © ${year} 37signals, LLC\n */` 12 | 13 | const plugins = [ 14 | json(), 15 | includePaths({ 16 | paths: [ "src" ], 17 | extensions: [ ".js" ] 18 | }), 19 | nodeResolve({ extensions: [ ".js" ] }), 20 | commonjs({ 21 | extensions: [ ".js" ] 22 | }), 23 | babel({ babelHelpers: "bundled" }), 24 | ] 25 | 26 | const defaultConfig = { 27 | context: "window", 28 | treeshake: false, 29 | plugins: plugins, 30 | watch: { 31 | include: "src/**" 32 | } 33 | } 34 | 35 | const terserConfig = terser({ 36 | mangle: true, 37 | compress: true, 38 | format: { 39 | comments: function (node, comment) { 40 | const text = comment.value 41 | const type = comment.type 42 | if (type == "comment2") { 43 | // multiline comment 44 | return /@license|Copyright/.test(text) 45 | } 46 | }, 47 | }, 48 | }) 49 | 50 | const compressedConfig = Object.assign({}, defaultConfig, { plugins: plugins.concat(terserConfig) }) 51 | 52 | export default [ 53 | { 54 | input: "src/trix/trix.js", 55 | output: [ 56 | { 57 | name: "Trix", 58 | file: "dist/trix.umd.js", 59 | format: "umd", 60 | banner 61 | }, 62 | { 63 | file: "dist/trix.esm.js", 64 | format: "es", 65 | banner 66 | } 67 | ], 68 | ...defaultConfig, 69 | }, 70 | { 71 | input: "src/trix/trix.js", 72 | output: [ 73 | { 74 | name: "Trix", 75 | file: "dist/trix.umd.min.js", 76 | format: "umd", 77 | banner, 78 | sourcemap: true 79 | }, 80 | { 81 | file: "dist/trix.esm.min.js", 82 | format: "es", 83 | banner, 84 | sourcemap: true 85 | } 86 | ], 87 | ...compressedConfig, 88 | }, 89 | { 90 | input: "src/test/test.js", 91 | output: { 92 | name: "TrixTests", 93 | file: "dist/test.js", 94 | format: "es", 95 | sourcemap: true, 96 | banner 97 | }, 98 | ...defaultConfig, 99 | }, 100 | { 101 | input: "src/inspector/inspector.js", 102 | output: { 103 | name: "TrixInspector", 104 | file: "dist/inspector.js", 105 | format: "es", 106 | sourcemap: true, 107 | banner 108 | }, 109 | ...defaultConfig, 110 | } 111 | ] 112 | -------------------------------------------------------------------------------- /src/inspector/control_element.js: -------------------------------------------------------------------------------- 1 | const KEY_EVENTS = "keydown keypress input".split(" ") 2 | const COMPOSITION_EVENTS = "compositionstart compositionupdate compositionend textInput".split(" ") 3 | const OBSERVER_OPTIONS = { 4 | attributes: true, 5 | childList: true, 6 | characterData: true, 7 | characterDataOldValue: true, 8 | subtree: true, 9 | } 10 | 11 | export default class ControlElement { 12 | constructor(editorElement) { 13 | this.didMutate = this.didMutate.bind(this) 14 | this.editorElement = editorElement 15 | this.install() 16 | } 17 | 18 | install() { 19 | this.createElement() 20 | this.logInputEvents() 21 | this.logMutations() 22 | } 23 | 24 | uninstall() { 25 | this.observer.disconnect() 26 | this.element.parentNode.removeChild(this.element) 27 | } 28 | 29 | createElement() { 30 | this.element = document.createElement("div") 31 | this.element.setAttribute("contenteditable", "") 32 | this.element.style.width = getComputedStyle(this.editorElement).width 33 | this.element.style.minHeight = "50px" 34 | this.element.style.border = "1px solid green" 35 | this.editorElement.parentNode.insertBefore(this.element, this.editorElement.nextSibling) 36 | } 37 | 38 | logInputEvents() { 39 | KEY_EVENTS.forEach((eventName) => { 40 | this.element.addEventListener(eventName, (event) => console.log(`${event.type}: keyCode = ${event.keyCode}`)) 41 | }) 42 | 43 | COMPOSITION_EVENTS.forEach((eventName) => { 44 | this.element.addEventListener(eventName, (event) => 45 | console.log(`${event.type}: data = ${JSON.stringify(event.data)}`) 46 | ) 47 | }) 48 | } 49 | 50 | logMutations() { 51 | this.observer = new window.MutationObserver(this.didMutate) 52 | this.observer.observe(this.element, OBSERVER_OPTIONS) 53 | } 54 | 55 | didMutate(mutations) { 56 | console.log(`Mutations (${mutations.length}):`) 57 | for (let index = 0; index < mutations.length; index++) { 58 | const mutation = mutations[index] 59 | console.log(` ${index + 1}. ${mutation.type}:`) 60 | switch (mutation.type) { 61 | case "characterData": 62 | console.log(` oldValue = ${JSON.stringify(mutation.oldValue)}, newValue = ${JSON.stringify(mutation.target.data)}`) 63 | break 64 | case "childList": 65 | Array.from(mutation.addedNodes).forEach((node) => { 66 | console.log(` node added ${inspectNode(node)}`) 67 | }) 68 | 69 | Array.from(mutation.removedNodes).forEach((node) => { 70 | console.log(` node removed ${inspectNode(node)}`) 71 | }) 72 | } 73 | } 74 | } 75 | } 76 | 77 | const inspectNode = function(node) { 78 | if (node.data) { 79 | return JSON.stringify(node.data) 80 | } else { 81 | return JSON.stringify(node.outerHTML) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/inspector/element.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | id-length, 3 | */ 4 | import { installDefaultCSSForTagName } from "trix/core/helpers" 5 | 6 | installDefaultCSSForTagName("trix-inspector", `\ 7 | %t { 8 | display: block; 9 | } 10 | 11 | %t { 12 | position: fixed; 13 | background: #fff; 14 | border: 1px solid #444; 15 | border-radius: 5px; 16 | padding: 10px; 17 | font-family: sans-serif; 18 | font-size: 12px; 19 | overflow: auto; 20 | word-wrap: break-word; 21 | } 22 | 23 | %t details { 24 | margin-bottom: 10px; 25 | } 26 | 27 | %t summary:focus { 28 | outline: none; 29 | } 30 | 31 | %t details .panel { 32 | padding: 10px; 33 | } 34 | 35 | %t .performance .metrics { 36 | margin: 0 0 5px 5px; 37 | } 38 | 39 | %t .selection .characters { 40 | margin-top: 10px; 41 | } 42 | 43 | %t .selection .character { 44 | display: inline-block; 45 | font-size: 8px; 46 | font-family: courier, monospace; 47 | line-height: 10px; 48 | vertical-align: middle; 49 | text-align: center; 50 | width: 10px; 51 | height: 10px; 52 | margin: 0 1px 1px 0; 53 | border: 1px solid #333; 54 | border-radius: 1px; 55 | background: #676666; 56 | color: #fff; 57 | } 58 | 59 | %t .selection .character.selected { 60 | background: yellow; 61 | color: #000; 62 | }\ 63 | `) 64 | 65 | export default class TrixInspector extends HTMLElement { 66 | connectedCallback() { 67 | this.editorElement = document.querySelector(`trix-editor[trix-id='${this.dataset.trixId}']`) 68 | this.views = this.createViews() 69 | 70 | this.views.forEach((view) => { 71 | view.render() 72 | this.appendChild(view.element) 73 | }) 74 | 75 | this.reposition() 76 | 77 | this.resizeHandler = this.reposition.bind(this) 78 | addEventListener("resize", this.resizeHandler) 79 | } 80 | 81 | disconnectedCallback() { 82 | removeEventListener("resize", this.resizeHandler) 83 | } 84 | 85 | createViews() { 86 | const views = Trix.Inspector.views.map((View) => new View(this.editorElement)) 87 | 88 | return views.sort((a, b) => a.title.toLowerCase() > b.title.toLowerCase()) 89 | } 90 | 91 | reposition() { 92 | const { top, right } = this.editorElement.getBoundingClientRect() 93 | 94 | this.style.top = `${top}px` 95 | this.style.left = `${right + 10}px` 96 | this.style.maxWidth = `${window.innerWidth - right - 40}px` 97 | this.style.maxHeight = `${window.innerHeight - top - 30}px` 98 | } 99 | } 100 | 101 | window.customElements.define("trix-inspector", TrixInspector) 102 | -------------------------------------------------------------------------------- /src/inspector/global.js: -------------------------------------------------------------------------------- 1 | window.Trix.Inspector = { 2 | views: [], 3 | 4 | registerView(constructor) { 5 | return this.views.push(constructor) 6 | }, 7 | 8 | install(editorElement) { 9 | this.editorElement = editorElement 10 | const element = document.createElement("trix-inspector") 11 | element.dataset.trixId = this.editorElement.trixId 12 | return document.body.appendChild(element) 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /src/inspector/inspector.js: -------------------------------------------------------------------------------- 1 | import "inspector/element" 2 | import "inspector/global" 3 | import "inspector/templates" 4 | import "inspector/control_element" 5 | import "inspector/views/debug_view" 6 | import "inspector/views/document_view" 7 | import "inspector/views/performance_view" 8 | import "inspector/views/render_view" 9 | import "inspector/views/selection_view" 10 | import "inspector/views/undo_view" 11 | -------------------------------------------------------------------------------- /src/inspector/templates.js: -------------------------------------------------------------------------------- 1 | import "inspector/templates/debug" 2 | import "inspector/templates/document" 3 | import "inspector/templates/performance" 4 | import "inspector/templates/render" 5 | import "inspector/templates/selection" 6 | import "inspector/templates/undo" 7 | -------------------------------------------------------------------------------- /src/inspector/templates/debug.js: -------------------------------------------------------------------------------- 1 | if (!window.JST) window.JST = {} 2 | 3 | window.JST["trix/inspector/templates/debug"] = function() { 4 | return `

5 | 9 |

10 | 11 |

12 | 13 |

14 | 15 |

16 | 20 |

` } 21 | -------------------------------------------------------------------------------- /src/inspector/templates/document.js: -------------------------------------------------------------------------------- 1 | if (!window.JST) window.JST = {} 2 | 3 | window.JST["trix/inspector/templates/document"] = function() { 4 | const details = this.document.getBlocks().map((block, index) => { 5 | const { text } = block 6 | const pieces = text.pieceList.toArray() 7 | 8 | return `
9 | 10 | Block ${block.id}, Index: ${index} 11 | 12 |
13 | Attributes: ${JSON.stringify(block.attributes)} 14 |
15 | 16 |
17 | HTML Attributes: ${JSON.stringify(block.htmlAttributes)} 18 |
19 | 20 |
21 |
22 | Text: ${text.id}, Pieces: ${pieces.length}, Length: ${text.getLength()} 23 |
24 |
25 | ${piecePartials(pieces).join("\n")} 26 |
27 |
28 |
` 29 | }) 30 | 31 | return details.join("\n") 32 | } 33 | 34 | const piecePartials = (pieces) => 35 | pieces.map((piece, index) =>`
36 |
37 | Piece ${piece.id}, Index: ${index} 38 |
39 |
40 | Attributes: ${JSON.stringify(piece.attributes)} 41 |
42 |
43 | ${JSON.stringify(piece.toString())} 44 |
45 |
`) 46 | -------------------------------------------------------------------------------- /src/inspector/templates/performance.js: -------------------------------------------------------------------------------- 1 | if (!window.JST) window.JST = {} 2 | 3 | window.JST["trix/inspector/templates/performance"] = function() { 4 | return Object.keys(this.data).map((name) => { 5 | const data = this.data[name] 6 | return dataMetrics(name, data, this.round) 7 | }).join("\n") 8 | } 9 | 10 | const dataMetrics = function(name, data, round) { 11 | let item = `${name} (${data.calls})
` 12 | 13 | if (data.calls > 0) { 14 | item += `
15 | Mean: ${round(data.mean)}ms
16 | Max: ${round(data.max)}ms
17 | Last: ${round(data.last)}ms 18 |
` 19 | 20 | return item 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/inspector/templates/render.js: -------------------------------------------------------------------------------- 1 | if (!window.JST) window.JST = {} 2 | 3 | window.JST["trix/inspector/templates/render"] = () => `Syncs: ${this.syncCount}` 4 | -------------------------------------------------------------------------------- /src/inspector/templates/selection.js: -------------------------------------------------------------------------------- 1 | if (!window.JST) window.JST = {} 2 | 3 | window.JST["trix/inspector/templates/selection"] = function() { 4 | return `Location range: [${this.locationRange[0].index}:${this.locationRange[0].offset}, ${this.locationRange[1].index}:${this.locationRange[1].offset}] 5 | ${charSpans(this.characters).join("\n")}` 6 | } 7 | 8 | const charSpans = (characters) => 9 | Array.from(characters).map( 10 | (char) => `${char.string}` 11 | ) 12 | -------------------------------------------------------------------------------- /src/inspector/templates/undo.js: -------------------------------------------------------------------------------- 1 | if (!window.JST) window.JST = {} 2 | 3 | window.JST["trix/inspector/templates/undo"] = () => 4 | `

Undo stack

5 |
    6 | ${entryList(this.undoEntries)} 7 |
8 |

Redo stack

9 |
    10 | ${entryList(this.redoEntries)} 11 |
` 12 | 13 | const entryList = (entries) => 14 | entries.map((entry) => 15 | `
  • ${entry.description} ${JSON.stringify({ 16 | selectedRange: entry.snapshot.selectedRange, 17 | context: entry.context, 18 | })}
  • `) 19 | -------------------------------------------------------------------------------- /src/inspector/view.js: -------------------------------------------------------------------------------- 1 | import { handleEvent } from "trix/core/helpers" 2 | 3 | export default class View { 4 | constructor(editorElement) { 5 | this.editorElement = editorElement 6 | this.editorController = this.editorElement.editorController 7 | this.editor = this.editorElement.editor 8 | this.compositionController = this.editorController.compositionController 9 | this.composition = this.editorController.composition 10 | 11 | this.element = document.createElement("details") 12 | if (this.getSetting("open") === "true") { 13 | this.element.open = true 14 | } 15 | this.element.classList.add(this.constructor.template) 16 | 17 | this.titleElement = document.createElement("summary") 18 | this.element.appendChild(this.titleElement) 19 | 20 | this.panelElement = document.createElement("div") 21 | this.panelElement.classList.add("panel") 22 | this.element.appendChild(this.panelElement) 23 | 24 | this.element.addEventListener("toggle", (event) => { 25 | if (event.target === this.element) { 26 | return this.didToggle() 27 | } 28 | }) 29 | 30 | if (this.events) { 31 | this.installEventHandlers() 32 | } 33 | } 34 | 35 | installEventHandlers() { 36 | for (const eventName in this.events) { 37 | const handler = this.events[eventName] 38 | const callback = (event) => { 39 | requestAnimationFrame(() => { 40 | handler.call(this, event) 41 | }) 42 | } 43 | 44 | handleEvent(eventName, { onElement: this.editorElement, withCallback: callback }) 45 | } 46 | } 47 | 48 | didToggle(event) { 49 | this.saveSetting("open", this.isOpen()) 50 | return this.render() 51 | } 52 | 53 | isOpen() { 54 | return this.element.hasAttribute("open") 55 | } 56 | 57 | getTitle() { 58 | return this.title || "" 59 | } 60 | 61 | render() { 62 | this.renderTitle() 63 | if (this.isOpen()) { 64 | this.panelElement.innerHTML = window.JST[`trix/inspector/templates/${this.constructor.template}`].apply(this) 65 | } 66 | } 67 | 68 | renderTitle() { 69 | this.titleElement.innerHTML = this.getTitle() 70 | } 71 | 72 | getSetting(key) { 73 | key = this.getSettingsKey(key) 74 | return window.sessionStorage?.[key] 75 | } 76 | 77 | saveSetting(key, value) { 78 | key = this.getSettingsKey(key) 79 | if (window.sessionStorage) { 80 | window.sessionStorage[key] = value 81 | } 82 | } 83 | 84 | getSettingsKey(key) { 85 | return `trix/inspector/${this.template}/${key}` 86 | } 87 | 88 | get title() { 89 | return this.constructor.title 90 | } 91 | 92 | get template() { 93 | return this.constructor.template 94 | } 95 | 96 | get events() { 97 | return this.constructor.events 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/inspector/views/debug_view.js: -------------------------------------------------------------------------------- 1 | import View from "inspector/view" 2 | 3 | import { handleEvent } from "trix/core/helpers" 4 | 5 | class DebugView extends View { 6 | static title = "Debug" 7 | static template = "debug" 8 | 9 | constructor() { 10 | super(...arguments) 11 | this.didToggleViewCaching = this.didToggleViewCaching.bind(this) 12 | this.didClickRenderButton = this.didClickRenderButton.bind(this) 13 | this.didClickParseButton = this.didClickParseButton.bind(this) 14 | this.didToggleControlElement = this.didToggleControlElement.bind(this) 15 | 16 | handleEvent("change", { 17 | onElement: this.element, 18 | matchingSelector: "input[name=viewCaching]", 19 | withCallback: this.didToggleViewCaching, 20 | }) 21 | handleEvent("click", { 22 | onElement: this.element, 23 | matchingSelector: "button[data-action=render]", 24 | withCallback: this.didClickRenderButton, 25 | }) 26 | handleEvent("click", { 27 | onElement: this.element, 28 | matchingSelector: "button[data-action=parse]", 29 | withCallback: this.didClickParseButton, 30 | }) 31 | handleEvent("change", { 32 | onElement: this.element, 33 | matchingSelector: "input[name=controlElement]", 34 | withCallback: this.didToggleControlElement, 35 | }) 36 | } 37 | 38 | didToggleViewCaching({ target }) { 39 | if (target.checked) { 40 | return this.compositionController.enableViewCaching() 41 | } else { 42 | return this.compositionController.disableViewCaching() 43 | } 44 | } 45 | 46 | didClickRenderButton() { 47 | return this.editorController.render() 48 | } 49 | 50 | didClickParseButton() { 51 | return this.editorController.reparse() 52 | } 53 | 54 | didToggleControlElement({ target }) { 55 | if (target.checked) { 56 | this.control = new Trix.Inspector.ControlElement(this.editorElement) 57 | } else { 58 | this.control?.uninstall() 59 | this.control = null 60 | } 61 | } 62 | } 63 | 64 | Trix.Inspector.registerView(DebugView) 65 | -------------------------------------------------------------------------------- /src/inspector/views/document_view.js: -------------------------------------------------------------------------------- 1 | import View from "inspector/view" 2 | 3 | class DocumentView extends View { 4 | static title = "Document" 5 | static template = "document" 6 | static events = { 7 | "trix-change": function() { 8 | return this.render() 9 | }, 10 | } 11 | 12 | render() { 13 | this.document = this.editor.getDocument() 14 | return super.render(...arguments) 15 | } 16 | } 17 | 18 | Trix.Inspector.registerView(DocumentView) 19 | -------------------------------------------------------------------------------- /src/inspector/views/performance_view.js: -------------------------------------------------------------------------------- 1 | import View from "inspector/view" 2 | const now = window.performance?.now ? () => performance.now() : () => new Date().getTime() 3 | 4 | class PerformanceView extends View { 5 | static title = "Performance" 6 | static template = "performance" 7 | 8 | constructor() { 9 | super(...arguments) 10 | this.documentView = this.compositionController.documentView 11 | 12 | this.data = {} 13 | this.track("documentView.render") 14 | this.track("documentView.sync") 15 | this.track("documentView.garbageCollectCachedViews") 16 | this.track("composition.replaceHTML") 17 | 18 | this.render() 19 | } 20 | 21 | track(methodPath) { 22 | this.data[methodPath] = { calls: 0, total: 0, mean: 0, max: 0, last: 0 } 23 | const parts = methodPath.split(".") 24 | const propertyNames = parts.slice(0, parts.length - 1) 25 | const methodName = parts[parts.length - 1] 26 | 27 | let object = this 28 | 29 | propertyNames.forEach((propertyName) => { 30 | object = object[propertyName] 31 | }) 32 | 33 | const original = object[methodName] 34 | 35 | object[methodName] = function() { 36 | const started = now() 37 | const result = original.apply(object, arguments) 38 | const timing = now() - started 39 | this.record(methodPath, timing) 40 | return result 41 | }.bind(this) 42 | } 43 | 44 | record(methodPath, timing) { 45 | const data = this.data[methodPath] 46 | data.calls += 1 47 | data.total += timing 48 | data.mean = data.total / data.calls 49 | if (timing > data.max) { 50 | data.max = timing 51 | } 52 | data.last = timing 53 | return this.render() 54 | } 55 | 56 | round(ms) { 57 | return Math.round(ms * 1000) / 1000 58 | } 59 | } 60 | 61 | Trix.Inspector.registerView(PerformanceView) 62 | -------------------------------------------------------------------------------- /src/inspector/views/render_view.js: -------------------------------------------------------------------------------- 1 | import View from "inspector/view" 2 | 3 | export default class RenderView extends View { 4 | static title = "Renders" 5 | static template = "render" 6 | static events = { 7 | "trix-render": function() { 8 | this.renderCount++ 9 | return this.render() 10 | }, 11 | 12 | "trix-sync": function() { 13 | this.syncCount++ 14 | return this.render() 15 | }, 16 | } 17 | 18 | constructor() { 19 | super(...arguments) 20 | this.renderCount = 0 21 | this.syncCount = 0 22 | } 23 | 24 | getTitle() { 25 | return `${this.title} (${this.renderCount})` 26 | } 27 | } 28 | 29 | Trix.Inspector.registerView(RenderView) 30 | -------------------------------------------------------------------------------- /src/inspector/views/selection_view.js: -------------------------------------------------------------------------------- 1 | import View from "inspector/view" 2 | import UTF16String from "trix/core/utilities/utf16_string" 3 | 4 | class SelectionView extends View { 5 | static title = "Selection" 6 | static template = "selection" 7 | static events = { 8 | "trix-selection-change": function() { 9 | return this.render() 10 | }, 11 | "trix-render": function() { 12 | return this.render() 13 | }, 14 | } 15 | 16 | render() { 17 | this.document = this.editor.getDocument() 18 | this.range = this.editor.getSelectedRange() 19 | this.locationRange = this.composition.getLocationRange() 20 | this.characters = this.getCharacters() 21 | return super.render(...arguments) 22 | } 23 | 24 | getCharacters() { 25 | const chars = [] 26 | const utf16string = UTF16String.box(this.document.toString()) 27 | const rangeIsExpanded = this.range[0] !== this.range[1] 28 | let position = 0 29 | while (position < utf16string.length) { 30 | let string = utf16string.charAt(position).toString() 31 | if (string === "\n") { 32 | string = "⏎" 33 | } 34 | const selected = rangeIsExpanded && position >= this.range[0] && position < this.range[1] 35 | chars.push({ string, selected }) 36 | position++ 37 | } 38 | return chars 39 | } 40 | 41 | getTitle() { 42 | return `${this.title} (${this.range.join()})` 43 | } 44 | } 45 | 46 | Trix.Inspector.registerView(SelectionView) 47 | -------------------------------------------------------------------------------- /src/inspector/views/undo_view.js: -------------------------------------------------------------------------------- 1 | import View from "inspector/view" 2 | 3 | class UndoView extends View { 4 | static title = "Undo" 5 | static template = "undo" 6 | static events = { 7 | "trix-change": function() { 8 | return this.render() 9 | }, 10 | } 11 | 12 | render() { 13 | this.undoEntries = this.editor.undoManager.undoEntries 14 | this.redoEntries = this.editor.undoManager.redoEntries 15 | return super.render(...arguments) 16 | } 17 | } 18 | 19 | Trix.Inspector.registerView(UndoView) 20 | -------------------------------------------------------------------------------- /src/inspector/watchdog.js: -------------------------------------------------------------------------------- 1 | import "inspector/watchdog/recorder" 2 | import "inspector/watchdog/player_element" 3 | 4 | Trix.Watchdog = {} 5 | -------------------------------------------------------------------------------- /src/inspector/watchdog/deserializer.js: -------------------------------------------------------------------------------- 1 | export default class Deserializer { 2 | constructor(document, snapshot) { 3 | this.document = document 4 | this.snapshot = snapshot 5 | this.tree = this.snapshot.tree 6 | this.selection = this.snapshot.selection 7 | this.deserializeTree() 8 | this.deserializeSelection() 9 | } 10 | 11 | deserializeTree() { 12 | this.nodes = {} 13 | this.element = this.deserializeNode(this.tree) 14 | } 15 | 16 | deserializeNode(serializedNode) { 17 | let node 18 | switch (serializedNode.name) { 19 | case "#text": 20 | node = this.deserializeTextNode(serializedNode) 21 | break 22 | case "#comment": 23 | node = this.deserializeComment(serializedNode) 24 | break 25 | default: 26 | node = this.deserializeElement(serializedNode) 27 | break 28 | } 29 | 30 | this.nodes[serializedNode.id] = node 31 | return node 32 | } 33 | 34 | deserializeTextNode({ value }) { 35 | return this.document.createTextNode(value) 36 | } 37 | 38 | deserializeComment({ value }) { 39 | return this.document.createComment(value) 40 | } 41 | 42 | deserializeChildren(serializedNode) { 43 | const children = serializedNode.children ? Array.from(serializedNode.children) : [] 44 | return children.map((child) => this.deserializeNode(child)) 45 | } 46 | 47 | deserializeElement(serializedNode) { 48 | const node = this.document.createElement(serializedNode.name) 49 | const object = serializedNode.attributes ? serializedNode.attributes : {} 50 | for (const name in object) { 51 | const value = object[name] 52 | node.setAttribute(name, value) 53 | } 54 | while (node.lastChild) { 55 | node.removeChild(node.lastChild) 56 | } 57 | this.deserializeChildren(serializedNode).forEach((childNode) => { 58 | node.appendChild(childNode) 59 | }) 60 | return node 61 | } 62 | 63 | deserializeSelection() { 64 | if (!this.selection) return 65 | 66 | const { start, end } = this.selection 67 | const startContainer = this.nodes[start.id] 68 | const endContainer = this.nodes[end.id] 69 | 70 | this.range = this.document.createRange() 71 | this.range.setStart(startContainer, start.offset) 72 | this.range.setEnd(endContainer, end.offset) 73 | return this.range 74 | } 75 | 76 | getElement() { 77 | return this.element 78 | } 79 | 80 | getRange() { 81 | return this.range 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/inspector/watchdog/player.js: -------------------------------------------------------------------------------- 1 | import "inspector/watchdog/recording" 2 | 3 | export default class Player { 4 | constructor(recording) { 5 | this.tick = this.tick.bind(this) 6 | this.recording = recording 7 | this.playing = false 8 | this.index = -1 9 | this.length = this.recording.getFrameCount() 10 | } 11 | 12 | play() { 13 | if (this.playing) return 14 | if (this.hasEnded()) { 15 | this.index = -1 16 | } 17 | this.playing = true 18 | this.delegate?.playerDidStartPlaying?.() 19 | return this.tick() 20 | } 21 | 22 | tick() { 23 | if (this.hasEnded()) { 24 | return this.stop() 25 | } else { 26 | this.seek(this.index + 1) 27 | const duration = this.getTimeToNextFrame() 28 | this.timeout = setTimeout(this.tick, duration) 29 | } 30 | } 31 | 32 | seek(index) { 33 | const previousIndex = this.index 34 | 35 | if (index < 0) { 36 | this.index = 0 37 | } else if (index >= this.length) { 38 | this.index = this.length - 1 39 | } else { 40 | this.index = index 41 | } 42 | 43 | if (this.index !== previousIndex) { 44 | return this.delegate?.playerDidSeekToIndex?.(index) 45 | } 46 | } 47 | 48 | stop() { 49 | if (!this.playing) return 50 | clearTimeout(this.timeout) 51 | this.timeout = null 52 | this.playing = false 53 | return this.delegate?.playerDidStopPlaying?.() 54 | } 55 | 56 | isPlaying() { 57 | return this.playing 58 | } 59 | 60 | hasEnded() { 61 | return this.index >= this.length - 1 62 | } 63 | 64 | getSnapshot() { 65 | return this.recording.getSnapshotAtFrameIndex(this.index) 66 | } 67 | 68 | getEvents() { 69 | return this.recording.getEventsUpToFrameIndex(this.index) 70 | } 71 | 72 | getTimeToNextFrame() { 73 | const current = this.recording.getTimestampAtFrameIndex(this.index) 74 | const next = this.recording.getTimestampAtFrameIndex(this.index + 1) 75 | const duration = current && next ? next - current : 0 76 | return Math.min(duration, 500) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/inspector/watchdog/player_controller.js: -------------------------------------------------------------------------------- 1 | import Player from "inspector/watchdog/player" 2 | import PlayerView from "inspector/watchdog/player_view" 3 | 4 | export default class PlayerController { 5 | constructor(element, recording) { 6 | this.element = element 7 | this.recording = recording 8 | this.player = new Player(this.recording) 9 | this.player.delegate = this 10 | 11 | this.view = new PlayerView(this.element) 12 | this.view.delegate = this 13 | 14 | this.view.setLength(this.player.length) 15 | this.player.seek(0) 16 | } 17 | 18 | play() { 19 | return this.player.play() 20 | } 21 | 22 | stop() { 23 | return this.player.stop() 24 | } 25 | 26 | playerViewDidClickPlayButton() { 27 | if (this.player.isPlaying()) { 28 | return this.player.stop() 29 | } else { 30 | return this.player.play() 31 | } 32 | } 33 | 34 | playerViewDidChangeSliderValue(value) { 35 | return this.player.seek(value) 36 | } 37 | 38 | playerDidSeekToIndex(index) { 39 | this.view.setIndex(index) 40 | 41 | const snapshot = this.player.getSnapshot(index) 42 | if (snapshot) { 43 | this.view.renderSnapshot(snapshot) 44 | } 45 | 46 | const events = this.player.getEvents(index) 47 | if (events) { 48 | return this.view.renderEvents(events) 49 | } 50 | } 51 | 52 | playerDidStartPlaying() { 53 | return this.view.playerDidStartPlaying() 54 | } 55 | 56 | playerDidStopPlaying() { 57 | return this.view.playerDidStopPlaying() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/inspector/watchdog/player_element.js: -------------------------------------------------------------------------------- 1 | import { installDefaultCSSForTagName } from "trix/core/helpers" 2 | 3 | import Recording from "inspector/watchdog/recording" 4 | import PlayerController from "inspector/watchdog/player_controller" 5 | 6 | installDefaultCSSForTagName("trix-watchdog-player", `\ 7 | %t > div { display: -webkit-flex; display: flex; font-size: 14px; margin: 10px 0 } 8 | %t > div > button { width: 65px } 9 | %t > div > input { width: 100%; -webkit-align-self: stretch; align-self: stretch; margin: 0 20px } 10 | %t > div > span { display: inline-block; text-align: center; width: 110px }\ 11 | `) 12 | 13 | class PlayerElement extends HTMLElement { 14 | static get observedAttributes() { return [ "src" ] } 15 | 16 | connectedCallback() { 17 | const url = this.getAttribute("src") 18 | if (url) { 19 | return this.fetchRecordingAtURL(url) 20 | } 21 | } 22 | 23 | attributeChangedCallback(attributeName, oldValue, newValue) { 24 | if (attributeName === "src") { 25 | return this.fetchRecordingAtURL(newValue) 26 | } 27 | } 28 | 29 | fetchRecordingAtURL(url) { 30 | this.activeRequest?.abort() 31 | this.activeRequest = new XMLHttpRequest() 32 | this.activeRequest.open("GET", url) 33 | this.activeRequest.send() 34 | 35 | this.activeRequest.onload = () => { 36 | const json = this.activeRequest.responseText 37 | this.activeRequest = null 38 | const recording = Recording.fromJSON(JSON.parse(json)) 39 | return this.loadRecording(recording) 40 | } 41 | } 42 | 43 | loadRecording(recording) { 44 | this.controller = new PlayerController(this, recording) 45 | } 46 | } 47 | 48 | window.customElements.define("trix-watchdog-player", PlayerElement) 49 | -------------------------------------------------------------------------------- /src/inspector/watchdog/recording.js: -------------------------------------------------------------------------------- 1 | export default class Recording { 2 | static fromJSON({ snapshots, frames }) { 3 | return new this(snapshots, frames) 4 | } 5 | 6 | constructor(snapshots = [], frames = []) { 7 | this.snapshots = snapshots 8 | this.frames = frames 9 | } 10 | 11 | recordSnapshot(snapshot) { 12 | const snapshotJSON = JSON.stringify(snapshot) 13 | if (snapshotJSON !== this.lastSnapshotJSON) { 14 | this.lastSnapshotJSON = snapshotJSON 15 | this.snapshots.push(snapshot) 16 | return this.recordEvent({ type: "snapshot" }) 17 | } 18 | } 19 | 20 | recordEvent(event) { 21 | const frame = [ this.getTimestamp(), this.snapshots.length - 1, event ] 22 | return this.frames.push(frame) 23 | } 24 | 25 | getSnapshotAtIndex(index) { 26 | if (index >= 0) { 27 | return this.snapshots[index] 28 | } 29 | } 30 | 31 | getSnapshotAtFrameIndex(frameIndex) { 32 | const snapshotIndex = this.getSnapshotIndexAtFrameIndex(frameIndex) 33 | return this.getSnapshotAtIndex(snapshotIndex) 34 | } 35 | 36 | getTimestampAtFrameIndex(index) { 37 | return this.frames[index]?.[0] 38 | } 39 | 40 | getSnapshotIndexAtFrameIndex(index) { 41 | return this.frames[index]?.[1] 42 | } 43 | 44 | getEventAtFrameIndex(index) { 45 | return this.frames[index]?.[2] 46 | } 47 | 48 | getEventsUpToFrameIndex(index) { 49 | return this.frames.slice(0, index + 1).map((frame) => frame[2]) 50 | } 51 | 52 | getFrameCount() { 53 | return this.frames.length 54 | } 55 | 56 | getTimestamp() { 57 | return new Date().getTime() 58 | } 59 | 60 | truncateToSnapshotCount(snapshotCount) { 61 | const offset = this.snapshots.length - snapshotCount 62 | if (offset < 0) return 63 | 64 | const { frames } = this 65 | this.frames = frames.map(([ timestamp, index, event ]) => { 66 | if (index >= offset) { 67 | return [ timestamp, index - offset, event ] 68 | } 69 | }).filter(frame => frame) 70 | 71 | this.snapshots = this.snapshots.slice(offset) 72 | } 73 | 74 | toJSON() { 75 | return { snapshots: this.snapshots, frames: this.frames } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/inspector/watchdog/serializer.js: -------------------------------------------------------------------------------- 1 | export default class Serializer { 2 | constructor(element) { 3 | this.element = element 4 | this.id = 0 5 | this.serializeTree() 6 | this.serializeSelection() 7 | } 8 | 9 | serializeTree() { 10 | this.ids = new Map() 11 | this.tree = this.serializeNode(this.element) 12 | } 13 | 14 | serializeNode(node) { 15 | const object = { id: ++this.id, name: node.nodeName } 16 | this.ids.set(node, object.id) 17 | 18 | switch (node.nodeType) { 19 | case Node.ELEMENT_NODE: 20 | this.serializeElementToObject(node, object) 21 | this.serializeElementChildrenToObject(node, object) 22 | break 23 | 24 | case Node.TEXT_NODE: 25 | case Node.COMMENT_NODE: 26 | this.serializeNodeValueToObject(node, object) 27 | break 28 | } 29 | 30 | return object 31 | } 32 | 33 | serializeElementToObject(node, object) { 34 | const attributes = {} 35 | let hasAttributes = false 36 | 37 | Array.from(node.attributes).forEach(({ name }) => { 38 | if (node.hasAttribute(name)) { 39 | let value = node.getAttribute(name) 40 | if (name === "src" && value.slice(0, 5) === "data:") { 41 | value = "data:" 42 | } 43 | attributes[name] = value 44 | hasAttributes = true 45 | } 46 | }) 47 | 48 | if (hasAttributes) { 49 | object.attributes = attributes 50 | } 51 | } 52 | 53 | serializeElementChildrenToObject(node, object) { 54 | if (node.childNodes.length) { 55 | object.children = Array.from(node.childNodes).map((childNode) => this.serializeNode(childNode)) 56 | } 57 | } 58 | 59 | serializeNodeValueToObject(node, object) { 60 | object.value = node.nodeValue 61 | } 62 | 63 | serializeSelection() { 64 | const selection = window.getSelection() 65 | if (selection.rangeCount <= 0) return 66 | 67 | const range = selection.getRangeAt(0) 68 | const startId = this.ids.get(range?.startContainer) 69 | const endId = this.ids.get(range?.endContainer) 70 | 71 | if (startId && endId) { 72 | this.selection = { 73 | start: { id: startId, offset: range.startOffset }, 74 | end: { id: endId, offset: range.endOffset }, 75 | } 76 | } 77 | } 78 | 79 | getSnapshot() { 80 | return { tree: this.tree, selection: this.selection } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/test/system.js: -------------------------------------------------------------------------------- 1 | import "test/system/accessibility_test" 2 | import "test/system/attachment_caption_test" 3 | import "test/system/attachment_gallery_test" 4 | import "test/system/attachment_test" 5 | import "test/system/basic_input_test" 6 | import "test/system/block_formatting_test" 7 | import "test/system/caching_test" 8 | import "test/system/canceled_input_test" 9 | import "test/system/composition_input_test" 10 | import "test/system/cursor_movement_test" 11 | import "test/system/custom_element_test" 12 | import "test/system/html_loading_test" 13 | import "test/system/html_reparsing_test" 14 | import "test/system/html_replacement_test" 15 | import "test/system/installation_process_test" 16 | import "test/system/level_2_input_test" 17 | import "test/system/list_formatting_test" 18 | import "test/system/morphing_test" 19 | import "test/system/mutation_input_test" 20 | import "test/system/pasting_test" 21 | import "test/system/text_formatting_test" 22 | import "test/system/undo_test" 23 | -------------------------------------------------------------------------------- /src/test/system/accessibility_test.js: -------------------------------------------------------------------------------- 1 | import { assert, skipIf, test, testGroup, triggerEvent } from "test/test_helper" 2 | import TrixEditorElement from "trix/elements/trix_editor_element" 3 | 4 | testGroup("Accessibility attributes", { template: "editor_default_aria_label" }, () => { 5 | test("sets the role to textbox", () => { 6 | const editor = document.getElementById("editor-without-labels") 7 | assert.equal(editor.getAttribute("role"), "textbox") 8 | }) 9 | 10 | skipIf(TrixEditorElement.formAssociated, "does not set aria-label when the element has no