├── .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 |
--------------------------------------------------------------------------------
/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