Repository: basecamp/trix Branch: main Commit: 2e46d5128f39 Files: 282 Total size: 1.2 MB Directory structure: gitextract_zvcmyhvl/ ├── .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 │ ├── bin/ │ │ └── rails │ ├── lib/ │ │ └── action_text/ │ │ ├── trix/ │ │ │ ├── engine.rb │ │ │ └── version.rb │ │ └── trix.rb │ └── test/ │ ├── application_system_test_case.rb │ ├── dummy/ │ │ ├── Rakefile │ │ ├── app/ │ │ │ ├── assets/ │ │ │ │ ├── images/ │ │ │ │ │ └── .keep │ │ │ │ └── stylesheets/ │ │ │ │ ├── actiontext.css │ │ │ │ └── application.css │ │ │ ├── controllers/ │ │ │ │ ├── application_controller.rb │ │ │ │ ├── concerns/ │ │ │ │ │ └── .keep │ │ │ │ └── messages_controller.rb │ │ │ ├── javascript/ │ │ │ │ └── application.js │ │ │ ├── models/ │ │ │ │ ├── application_record.rb │ │ │ │ ├── concerns/ │ │ │ │ │ └── .keep │ │ │ │ └── message.rb │ │ │ └── views/ │ │ │ ├── active_storage/ │ │ │ │ └── blobs/ │ │ │ │ └── _blob.html.erb │ │ │ ├── layouts/ │ │ │ │ ├── action_text/ │ │ │ │ │ └── contents/ │ │ │ │ │ └── _content.html.erb │ │ │ │ ├── application.html.erb │ │ │ │ ├── mailer.html.erb │ │ │ │ └── mailer.text.erb │ │ │ ├── messages/ │ │ │ │ ├── _form.html.erb │ │ │ │ ├── index.html.erb │ │ │ │ └── new.html.erb │ │ │ └── pwa/ │ │ │ ├── manifest.json.erb │ │ │ └── service-worker.js │ │ ├── bin/ │ │ │ ├── dev │ │ │ ├── importmap │ │ │ ├── rails │ │ │ ├── rake │ │ │ └── setup │ │ ├── config/ │ │ │ ├── application.rb │ │ │ ├── boot.rb │ │ │ ├── cable.yml │ │ │ ├── database.yml │ │ │ ├── environment.rb │ │ │ ├── environments/ │ │ │ │ ├── development.rb │ │ │ │ ├── production.rb │ │ │ │ └── test.rb │ │ │ ├── importmap.rb │ │ │ ├── initializers/ │ │ │ │ ├── assets.rb │ │ │ │ ├── content_security_policy.rb │ │ │ │ ├── filter_parameter_logging.rb │ │ │ │ └── inflections.rb │ │ │ ├── locales/ │ │ │ │ └── en.yml │ │ │ ├── puma.rb │ │ │ ├── routes.rb │ │ │ └── storage.yml │ │ ├── config.ru │ │ ├── db/ │ │ │ ├── migrate/ │ │ │ │ ├── 20250926170812_create_active_storage_tables.active_storage.rb │ │ │ │ ├── 20250926170813_create_action_text_tables.action_text.rb │ │ │ │ └── 20250926170921_create_messages.rb │ │ │ └── schema.rb │ │ └── public/ │ │ ├── 400.html │ │ ├── 404.html │ │ ├── 406-unsupported-browser.html │ │ ├── 422.html │ │ └── 500.html │ ├── fixtures/ │ │ └── action_text/ │ │ └── rich_texts.yml │ ├── system/ │ │ └── action_text_test.rb │ └── test_helper.rb ├── assets/ │ ├── index.html │ ├── test.html │ ├── trix/ │ │ ├── images/ │ │ │ └── README.md │ │ └── stylesheets/ │ │ ├── attachments.scss │ │ ├── content.scss │ │ ├── editor.scss │ │ ├── icons.scss │ │ ├── media-queries.scss │ │ ├── selection.scss │ │ └── toolbar.scss │ └── trix.scss ├── babel.config.json ├── bin/ │ ├── ci │ ├── sass-build │ └── setup ├── package.json ├── rollup.config.js ├── src/ │ ├── inspector/ │ │ ├── control_element.js │ │ ├── debugger.js │ │ ├── element.js │ │ ├── global.js │ │ ├── inspector.js │ │ ├── templates/ │ │ │ ├── debug.js │ │ │ ├── document.js │ │ │ ├── performance.js │ │ │ ├── render.js │ │ │ ├── selection.js │ │ │ └── undo.js │ │ ├── templates.js │ │ ├── view.js │ │ ├── views/ │ │ │ ├── debug_view.js │ │ │ ├── document_view.js │ │ │ ├── performance_view.js │ │ │ ├── render_view.js │ │ │ ├── selection_view.js │ │ │ └── undo_view.js │ │ ├── watchdog/ │ │ │ ├── deserializer.js │ │ │ ├── player.js │ │ │ ├── player_controller.js │ │ │ ├── player_element.js │ │ │ ├── player_view.js │ │ │ ├── recorder.js │ │ │ ├── recording.js │ │ │ └── serializer.js │ │ └── watchdog.js │ ├── test/ │ │ ├── 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 │ │ ├── system.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 │ │ │ │ └── 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/ │ │ │ ├── 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 │ │ └── unit.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/ │ │ │ ├── index.js │ │ │ ├── operation.js │ │ │ └── utf16_string.js │ │ └── utilities.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 └── web-test-runner.config.mjs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc ================================================ { "parser": "babel-eslint", "extends": "eslint:recommended", "rules": { "array-bracket-spacing": ["error", "always"], "block-spacing": ["error", "always"], "camelcase": ["error"], "comma-spacing": ["error"], "curly": ["error", "multi-line"], "dot-notation": ["error"], "eol-last": ["error"], "getter-return": ["error"], "id-length": ["error", { "properties": "never", "exceptions": ["_", "i", "j", "n"] }], "keyword-spacing": ["error"], "no-extra-parens": ["error"], "no-multi-spaces": ["error", { "exceptions": { "VariableDeclarator": true } }], "no-multiple-empty-lines": ["error", { "max": 2 }], "no-restricted-globals": ["error", "event"], "no-trailing-spaces": ["error"], "no-unused-vars": ["error", { "vars": "all", "args": "none" }], "no-var": ["error"], "object-curly-spacing": ["error", "always"], "prefer-const": ["error"], "quotes": ["error", "double"], "semi": ["error", "never"], "sort-imports": ["error", { "ignoreDeclarationSort": true }] }, "ignorePatterns": ["dist/**/*.js", "**/vendor/**/*.js", "action_text-trix/**/*.js"], "globals": { "after": true, "getComposition": true, "getDocument": true, "getEditor": true, "getEditorController": true, "getEditorElement": true, "getSelectionManager": true, "getToolbarElement": true, "QUnit": true, "rangy": true, "Trix": true }, "env": { "browser": true, "node": true, "es6": true } } ================================================ FILE: .github/ISSUE_TEMPLATE.md ================================================ Describe the bug or issue here… ##### Steps to Reproduce 1. 2. 3. ##### Details * Trix version: * Browser name and version: * Operating system: ================================================ FILE: .github/stale.yml ================================================ # Number of days of inactivity before an issue becomes stale daysUntilStale: 90 # Number of days of inactivity before a stale issue is closed daysUntilClose: 7 # Issues with these labels will never be considered stale exemptLabels: - bug - enhancement - pinned - security - WIP # Label to use when marking an issue as stale staleLabel: stale # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale after 90 days of inactivity. It will be closed if no further activity occurs. pulls: markComment: > This pull request has been automatically marked as stale after 90 days of inactivity. It will be closed if no further activity occurs. # Comment to post when closing a stale issue. Set to `false` to disable closeComment: false ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI concurrency: group: "${{github.workflow}}-${{github.ref}}" cancel-in-progress: true on: workflow_dispatch: push: branches: [ main ] pull_request: types: [opened, synchronize] branches: [ '*' ] env: SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} SAUCE_REGION: us SAUCE_TUNNEL_IDENTIFIER: trix-${{ github.run_id }} jobs: build: name: Browser tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: node-version: 18 cache: "yarn" - name: Install Dependencies run: yarn install --frozen-lockfile - name: Start Sauce Connect if: ${{ env.SAUCE_ACCESS_KEY != '' }} uses: saucelabs/sauce-connect-action@v3 with: username: ${{ env.SAUCE_USERNAME }} accessKey: ${{ env.SAUCE_ACCESS_KEY }} tunnelName: ${{ env.SAUCE_TUNNEL_IDENTIFIER }} region: ${{ env.SAUCE_REGION }} proxyLocalhost: allow - name: Install Playwright Browsers if: ${{ env.SAUCE_ACCESS_KEY == '' }} run: npx playwright install --with-deps chromium - run: bin/ci - name: Fail when generated npm changes are not checked-in run: | git update-index --refresh && git diff-index --quiet HEAD -- rails-tests: name: Downstream Rails integration tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: node-version: 18 cache: "yarn" - uses: ruby/setup-ruby-pkgs@v1 with: ruby-version: "3.4" apt-get: libvips-tools - name: Install Dependencies run: yarn install --frozen-lockfile - name: Packaging run: yarn build - name: Clone Rails run: git clone --depth=1 https://github.com/rails/rails - name: Configure Rails run: | cd rails yarn install --frozen-lockfile bundle add action_text-trix --path ".." bundle show --paths action_text-trix - name: Action Text tests env: RACK_ENV: test # see https://github.com/rails/rails/issues/56563 run: | cd rails/actiontext bundle exec rake test test:system action_text-trix: name: Action Text tests runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - ruby: "3.2" rails_branch: 7-2-stable - ruby: "3.3" rails_branch: 7-2-stable - ruby: "3.4" rails_branch: 7-2-stable - ruby: "3.2" rails_branch: 8-0-stable - ruby: "3.3" rails_branch: 8-0-stable - ruby: "3.4" rails_branch: 8-0-stable - ruby: "3.2" rails_branch: 8-1-stable - ruby: "3.3" rails_branch: 8-1-stable - ruby: "3.4" rails_branch: 8-1-stable - ruby: "4.0" rails_branch: 8-1-stable - ruby: "3.3" rails_branch: main experimental: true - ruby: "3.4" rails_branch: main experimental: true - ruby: "4.0" rails_branch: main experimental: true - ruby: head rails_branch: main experimental: true steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: node-version: 18 cache: "yarn" - uses: ruby/setup-ruby@v1 env: RAILS_BRANCH: ${{ matrix.rails_branch }} with: working-directory: "./action_text-trix" ruby-version: ${{ matrix.ruby }} bundler-cache: true - name: Run tests env: RAILS_BRANCH: ${{ matrix.rails_branch }} continue-on-error: ${{ matrix.experimental || false }} working-directory: "./action_text-trix" run: bin/rails test:all ================================================ FILE: .gitignore ================================================ yarn-error.log package-lock.json /dist /node_modules /tmp ================================================ FILE: .node-version ================================================ 18.18.0 ================================================ FILE: .npmignore ================================================ .DS_Store /node_modules /.github /bin /assets yarn-error.log yarn.lock ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the [project maintainers](#project-maintainers). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Project Maintainers * Javan Makhmali <> * Sam Stephenson <> ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ ================================================ FILE: LICENSE ================================================ Copyright (c) 37signals, LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Trix ### A Rich Text Editor for Everyday Writing **Compose beautifully formatted text in your web application.** Trix is a WYSIWYG editor for writing messages, comments, articles, and lists—the simple documents most web apps are made of. It features a sophisticated document model, support for embedded attachments, and outputs terse and consistent HTML. Trix is an open-source project from [37signals](https://37signals.com), the creators of [Ruby on Rails](http://rubyonrails.org/). Millions of people trust their text to us, and we built Trix to give them the best possible editing experience. See Trix in action in [Basecamp](https://basecamp.com). ### Different By Design When Trix was designed in 2014, most WYSIWYG editors were wrappers around HTML’s `contenteditable` and `execCommand` APIs, designed by Microsoft to support live editing of web pages in Internet Explorer 5.5, and [eventually reverse-engineered](https://blog.whatwg.org/the-road-to-html-5-contenteditable#history) and copied by other browsers. Because these APIs were not fully specified or documented, and because WYSIWYG HTML editors are enormous in scope, each browser’s implementation has its own set of bugs and quirks, and JavaScript developers are left to resolve the inconsistencies. Trix sidestepped these inconsistencies by treating `contenteditable` as an I/O device: when input makes its way to the editor, Trix converts that input into an editing operation on its internal document model, then re-renders that document back into the editor. This gives Trix complete control over what happens after every keystroke, and avoids the need to use `execCommand` at all. This is the approach that all modern, production ready, WYSIWYG editors now take. ### Built on Web standards
Trix supports all evergreen, self-updating desktop and mobile browsers.
Trix is built with established web standards, notably [Custom Elements](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements), [Element Internals](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals), [Mutation Observer](https://dom.spec.whatwg.org/#mutation-observers), and [Promises](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise). # Getting Started Trix comes bundled in ESM and UMD formats and works with any asset packaging system. The easiest way to start with Trix is including it from an npm CDN in the `` of your page: ```html … ``` `trix.css` includes default styles for the Trix toolbar, editor, and attachments. Skip this file if you prefer to define these styles yourself. Alternatively, you can install the npm package and import it in your application: ```js import Trix from "trix" document.addEventListener("trix-before-initialize", () => { // Change Trix.config if you need }) ``` ## Creating an Editor Place an empty `` tag on the page. Trix will automatically insert a separate `` before the editor. Like an HTML ` ================================================ FILE: assets/test.html ================================================ Test Suite
================================================ FILE: assets/trix/images/README.md ================================================ # Trix Icons 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. [1]: https://github.com/google/material-design-icons [2]: https://github.com/google/material-design-icons/blob/master/LICENSE ================================================ FILE: assets/trix/stylesheets/attachments.scss ================================================ @import "./icons"; @import "./selection"; trix-editor { [data-trix-mutable], [data-trix-cursor-target] { @extend %invisible-selection; } [data-trix-mutable] { * { @extend %invisible-selection; } &:not(.attachment__caption-editor) { @extend %disable-selection; } &.attachment__caption-editor:focus { @extend %visible-selection; } &.attachment { &.attachment--file { box-shadow: 0 0 0 2px highlight; border-color: transparent; } img { box-shadow: 0 0 0 2px highlight; } } } .attachment { position: relative; &:hover { cursor: default; } } .attachment--preview { .attachment__caption:hover { cursor: text; } } .attachment__progress { position: absolute; z-index: 1; height: 20px; top: calc(50% - 10px); left: 5%; width: 90%; opacity: 0.9; transition: opacity 200ms ease-in; &[value="100"] { opacity: 0; } } .attachment__caption-editor { display: inline-block; width: 100%; margin: 0; padding: 0; font-size: inherit; font-family: inherit; line-height: inherit; color: inherit; text-align: center; vertical-align: top; border: none; outline: none; -webkit-appearance: none; -moz-appearance: none; } .attachment__toolbar { position: absolute; z-index: 1; top: -0.9em; left: 0; width: 100%; text-align: center; } .trix-button-group { display: inline-flex; } .trix-button { position: relative; float: left; // Collapse whitespace between elements color: #666; white-space: nowrap; font-size: 80%; padding: 0 0.8em; margin: 0; outline: none; border: none; border-radius: 0; background: transparent; &:not(:first-child) { border-left: 1px solid #ccc; } &.trix-active { background: #cbeefa; } &:not(:disabled) { cursor: pointer; } } .trix-button--remove { text-indent: -9999px; display: inline-block; padding: 0; outline: none; width: 1.8em; height: 1.8em; line-height: 1.8em; border-radius: 50%; background-color: #fff; border: 2px solid highlight; box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.25); &::before { display: inline-block; position: absolute; top: 0; right: 0; bottom: 0; left: 0; opacity: 0.7; content: ""; background-image: $icon-remove; background-position: center; background-repeat: no-repeat; background-size: 90%; } &:hover { border-color: #333; &::before { opacity: 1; } } } .attachment__metadata-container { position: relative; } .attachment__metadata { position: absolute; left: 50%; top: 2em; transform: translate(-50%, 0); max-width: 90%; padding: 0.1em 0.6em; font-size: 0.8em; color: #fff; background-color: rgba(0, 0, 0, 0.7); border-radius: 3px; .attachment__name { display: inline-block; max-width: 100%; vertical-align: bottom; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .attachment__size { margin-left: 0.2em; white-space: nowrap; } } } ================================================ FILE: assets/trix/stylesheets/content.scss ================================================ $quote-border-width: 0.3em; $quote-margin-start: 0.3em; $quote-padding-start: 0.6em; .trix-content { line-height: 1.5; overflow-wrap: break-word; word-break: break-word; * { box-sizing: border-box; margin: 0; padding: 0; } h1 { font-size: 1.2em; line-height: 1.2; } blockquote { border: 0 solid #ccc; border-left-width: $quote-border-width; margin-left: $quote-margin-start; padding-left: $quote-padding-start; } [dir=rtl] blockquote, blockquote[dir=rtl] { border-width: 0; border-right-width: $quote-border-width; margin-right: $quote-margin-start; padding-right: $quote-padding-start; } li { margin-left: 1em; } [dir=rtl] li { margin-right: 1em; } pre { display: inline-block; width: 100%; vertical-align: top; font-family: monospace; font-size: 0.9em; padding: 0.5em; white-space: pre; background-color: #eee; overflow-x: auto; } img { max-width: 100%; height: auto; } .attachment { display: inline-block; position: relative; max-width: 100%; a { color: inherit; text-decoration: none; &:hover, &:visited:hover { color: inherit; } } } .attachment__caption { text-align: center; .attachment__name + .attachment__size { &::before { content: ' \2022 '; } } } .attachment--preview { width: 100%; text-align: center; .attachment__caption { color: #666; font-size: 0.9em; line-height: 1.2; } } .attachment--file { color: #333; line-height: 1; margin: 0 2px 2px 2px; padding: 0.4em 1em; border: 1px solid #bbb; border-radius: 5px; } .attachment-gallery { display: flex; flex-wrap: wrap; position: relative; .attachment { flex: 1 0 33%; padding: 0 0.5em; max-width: 33%; } &.attachment-gallery--2, &.attachment-gallery--4 { .attachment { flex-basis: 50%; max-width: 50%; } } } } ================================================ FILE: assets/trix/stylesheets/editor.scss ================================================ trix-editor { border: 1px solid #bbb; border-radius: 3px; margin: 0; padding: 0.4em 0.6em; min-height: 5em; outline: none; } ================================================ FILE: assets/trix/stylesheets/icons.scss ================================================ $icon-attach: svg('trix/images/attach.svg'); $icon-bold: svg('trix/images/bold.svg'); $icon-bullets: svg('trix/images/bullets.svg'); $icon-code: svg('trix/images/code.svg'); $icon-heading-1: svg('trix/images/heading_1.svg'); $icon-italic: svg('trix/images/italic.svg'); $icon-link: svg('trix/images/link.svg'); $icon-nesting-level-decrease: svg('trix/images/nesting_level_decrease.svg'); $icon-nesting-level-increase: svg('trix/images/nesting_level_increase.svg'); $icon-numbers: svg('trix/images/numbers.svg'); $icon-quote: svg('trix/images/quote.svg'); $icon-redo: svg('trix/images/redo.svg'); $icon-remove: svg('trix/images/remove.svg'); $icon-strike: svg('trix/images/strike.svg'); $icon-undo: svg('trix/images/undo.svg'); ================================================ FILE: assets/trix/stylesheets/media-queries.scss ================================================ $phone-width: 768px; @mixin phone { @media (max-width: #{$phone-width}) { @content; } } ================================================ FILE: assets/trix/stylesheets/selection.scss ================================================ %disable-selection { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } %invisible-selection { &::-moz-selection { background: none; } &::selection { background: none; } } %visible-selection { &::-moz-selection { background: highlight; } &::selection { background: highlight; } } ================================================ FILE: assets/trix/stylesheets/toolbar.scss ================================================ @import "./media-queries"; @import "./icons"; $font-size-normal: 0.75em; $opacity-normal: 0.6; $opacity-disabled: 0.125; $opacity-active: 1; trix-toolbar { * { box-sizing: border-box; } .trix-button-row { display: flex; flex-wrap: nowrap; justify-content: space-between; overflow-x: auto; } .trix-button-group { display: flex; margin-bottom: 10px; border: 1px solid #bbb; border-top-color: #ccc; border-bottom-color: #888; border-radius: 3px; &:not(:first-child) { margin-left: 1.5vw; @include phone { margin-left: 0; } } } .trix-button-group-spacer { flex-grow: 1; @include phone { display: none; } } .trix-button { position: relative; float: left; // Collapse whitespace between elements color: rgba(0,0,0, $opacity-normal); font-size: $font-size-normal; font-weight: 600; white-space: nowrap; padding: 0 0.5em; margin: 0; outline: none; border: none; border-bottom: 1px solid #ddd; border-radius: 0; background: transparent; &:not(:first-child) { border-left: 1px solid #ccc; } &.trix-active { background: #cbeefa; color: rgba(0,0,0, $opacity-active); } &:not(:disabled) { cursor: pointer; } &:disabled { color: rgba(0,0,0, $opacity-disabled); } @include phone { letter-spacing: -0.01em; padding: 0 0.3em; } } .trix-button--icon { font-size: inherit; width: 2.6em; height: 1.6em; max-width: calc(0.8em + 4vw); text-indent: -9999px; @include phone { height: 2em; max-width: calc(0.8em + 3.5vw); } &::before { display: inline-block; position: absolute; top: 0; right: 0; bottom: 0; left: 0; opacity: $opacity-normal; content: ""; background-position: center; background-repeat: no-repeat; background-size: contain; @include phone { right: 6%; left: 6%; } } &.trix-active::before { opacity: $opacity-active; } &:disabled::before { opacity: $opacity-disabled; } } .trix-button--icon-attach::before { background-image: $icon-attach; top: 8%; bottom: 4%; } .trix-button--icon-bold::before { background-image: $icon-bold; } .trix-button--icon-italic::before { background-image: $icon-italic; } .trix-button--icon-link::before { background-image: $icon-link; } .trix-button--icon-strike::before { background-image: $icon-strike; } .trix-button--icon-quote::before { background-image: $icon-quote; } .trix-button--icon-heading-1::before { background-image: $icon-heading-1; } .trix-button--icon-code::before { background-image: $icon-code; } .trix-button--icon-bullet-list::before { background-image: $icon-bullets; } .trix-button--icon-number-list::before { background-image: $icon-numbers; } .trix-button--icon-undo::before { background-image: $icon-undo; } .trix-button--icon-redo::before { background-image: $icon-redo; } .trix-button--icon-decrease-nesting-level::before { background-image: $icon-nesting-level-decrease; } .trix-button--icon-increase-nesting-level::before { background-image: $icon-nesting-level-increase; } .trix-dialogs { position: relative; } .trix-dialog { position: absolute; top: 0; left: 0; right: 0; font-size: $font-size-normal; padding: 15px 10px; background: #fff; box-shadow: 0 0.3em 1em #ccc; border-top: 2px solid #888; border-radius: 5px; z-index: 5; } .trix-input--dialog { font-size: inherit; font-weight: normal; padding: 0.5em 0.8em; margin: 0 10px 0 0; border-radius: 3px; border: 1px solid #bbb; background-color: #fff; box-shadow: none; outline: none; -webkit-appearance: none; -moz-appearance: none; &.validate:invalid { box-shadow: #F00 0px 0px 1.5px 1px; } } .trix-button--dialog { font-size: inherit; padding: 0.5em; border-bottom: none; } .trix-dialog--link { max-width: 600px; } .trix-dialog__link-fields { display: flex; align-items: baseline; .trix-input { flex: 1; } .trix-button-group { flex: 0 0 content; margin: 0; } } } ================================================ FILE: assets/trix.scss ================================================ @import "trix/stylesheets/editor"; @import "trix/stylesheets/toolbar"; @import "trix/stylesheets/attachments"; @import "trix/stylesheets/content"; ================================================ FILE: babel.config.json ================================================ { "presets": [ ["@babel/preset-env", { "targets": { "chrome": "80", "safari": "12.1", "edge": "80", "firefox": "88" } } ] ] } ================================================ FILE: bin/ci ================================================ #!/usr/bin/env bash set -e if [ -n "$CI" ]; then echo "GITHUB_WORKFLOW: $GITHUB_WORKFLOW" echo "GITHUB_RUN_NUMBER: $GITHUB_RUN_NUMBER" echo "GITHUB_RUN_ID: $GITHUB_RUN_ID" echo "GITHUB_ACTOR: $GITHUB_ACTOR" echo "GITHUB_EVENT_NAME: $GITHUB_EVENT_NAME" echo "GITHUB_SHA: $GITHUB_SHA" echo "GITHUB_REF: $GITHUB_REF" echo "GITHUB_HEAD_REF: $GITHUB_HEAD_REF" echo "GITHUB_BASE_REF: $GITHUB_BASE_REF" fi yarn test ================================================ FILE: bin/sass-build ================================================ #!/usr/bin/env node const path = require("path") const fs = require("fs") const sass = require("sass") const { optimize } = require("svgo") const chokidar = require("chokidar") const args = process.argv.slice(2) if (args.length < 2) { console.error("Usage: bin/sass-build (--watch)") process.exit(1) } const inputFile = path.resolve(args[0]) const outputFiles = args.slice(1).map(outputPath => path.resolve(outputPath)) const watchMode = args.includes("--watch") const basePath = path.dirname(inputFile) const functions = { "svg($file)": (args) => { const fileName = args[0].assertString().text const filePath = path.resolve(basePath, fileName) let svgContent = fs.readFileSync(filePath, "utf8") svgContent = optimize(svgContent, { multipass: true, datauri: "enc" }) return new sass.SassString(`url("${svgContent.data}")`, { quotes: false }) } } function compile() { try { const result = sass.compile(inputFile, { functions }) outputFiles.forEach(outputFile => fs.writeFileSync(outputFile, result.css, "utf8")) } catch (error) { console.error("Error compiling SCSS:", error.message) } } compile() if (watchMode) { console.log(`Watching for SASS file changes under ${basePath}...`) chokidar.watch(basePath).on("change", (filePath) => { if (!filePath.endsWith(".scss")) return console.log(`[${new Date().toLocaleTimeString()}] ${filePath} changed. Recompiling...`) compile() }) } ================================================ FILE: bin/setup ================================================ #!/usr/bin/env bash set -eo pipefail # Use binstubs. Work from the root dir. app_root="$( cd "$(dirname "$0")/.."; pwd )" # Prefer bin/ executables export PATH="$app_root/bin:$PATH" if [ "$1" = "-v" ]; then exec 3>&1 else exec 3>/dev/null exec 4>&1 trap 'echo "Setup failed - run \`bin/setup -v\` to see the error output" >&4' ERR fi brew_install_missing() { if which brew > /dev/null; then if ! which "$1" > /dev/null; then echo " -- Installing Homebrew package: $@" brew reinstall "$@" fi else return 1 fi } abort() { echo "$@" return 2 } echo "--- Installing Ruby gems" { if which rbenv > /dev/null; then rbenv install --skip-existing else if ! which ruby > /dev/null; then 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" fi fi gem list -i bundler >/dev/null 2>&1 || gem install bundler bundle check || bundle install } >&3 2>&1 echo "--- Installing npm modules" { if ! which npm > /dev/null; then brew_install_missing "npm" || abort "Can't find or install npm. Install it from https://nodejs.org" fi npm install } >&3 2>&1 if [ -d "$HOME/.pow" ]; then echo "--- Setting up Pow" { ln -nfs "$app_root" "$HOME/.pow/trix" mkdir -p tmp touch tmp/restart.txt } >&3 2>&1 fi echo echo "Done!" if [ -L "$HOME/.pow/trix" ]; then echo " * Open http://trix.dev to develop in-browser" else echo " * Run \`bin/rackup\` to develop in-browser" fi echo " * Run \`bin/blade build\` to build Trix" ================================================ FILE: package.json ================================================ { "name": "trix", "version": "2.1.17", "description": "A rich text editor for everyday writing", "main": "dist/trix.umd.min.js", "module": "dist/trix.esm.min.js", "style": "dist/trix.css", "files": [ "dist/*.css", "dist/*.js", "dist/*.map", "src/{inspector,trix}/*.js" ], "repository": { "type": "git", "url": "git+https://github.com/basecamp/trix.git" }, "keywords": [ "rich text", "wysiwyg", "editor" ], "author": "37signals, LLC", "license": "MIT", "bugs": { "url": "https://github.com/basecamp/trix/issues" }, "homepage": "https://trix-editor.org/", "devDependencies": { "@babel/core": "^7.16.0", "@babel/preset-env": "^7.16.4", "@rollup/plugin-babel": "^5.3.0", "@rollup/plugin-commonjs": "^22.0.2", "@rollup/plugin-json": "^4.1.0", "@rollup/plugin-node-resolve": "^13.3.0", "@web/dev-server": "^0.1.34", "@web/test-runner": "^0.20.2", "@web/test-runner-playwright": "^0.11.1", "@web/test-runner-webdriver": "^0.9.0", "babel-eslint": "^10.1.0", "chokidar": "^4.0.2", "concurrently": "^7.4.0", "eslint": "^7.32.0", "esm": "^3.2.25", "idiomorph": "^0.7.3", "qunit": "2.19.1", "rangy": "^1.3.0", "rollup": "^2.56.3", "rollup-plugin-includepaths": "^0.2.4", "rollup-plugin-terser": "^7.0.2", "sass": "^1.83.0", "source-map": "^0.7.6", "svgo": "^2.8.0", "webdriverio": "^7.19.5" }, "resolutions": { "webdriverio": "^7.19.5" }, "scripts": { "build-css": "bin/sass-build assets/trix.scss dist/trix.css action_text-trix/app/assets/stylesheets/trix.css", "build-js": "rollup -c", "build-assets": "cp -f assets/*.html dist/", "build-ruby": "rake -C action_text-trix sync", "build": "yarn run build-js && yarn run build-css && yarn run build-assets && yarn run build-ruby", "watch": "rollup -c -w", "lint": "eslint .", "pretest": "yarn run lint && yarn run build", "test": "web-test-runner", "test:watch": "web-test-runner --watch", "version": "yarn build && git add action_text-trix", "prerelease": "yarn version && yarn test", "release-npm": "npm adduser && npm publish", "release-ruby": "rake -C action_text-trix release", "release": "yarn run release-npm && yarn run release-ruby", "postrelease": "git push && git push --tags", "dev": "web-dev-server --app-index index.html --root-dir dist --node-resolve --open", "start": "yarn build-assets && concurrently --kill-others --names js,css,dev-server 'yarn watch' 'yarn build-css --watch' 'yarn dev'" }, "dependencies": { "dompurify": "^3.2.5" }, "engines": { "node": ">= 18" } } ================================================ FILE: rollup.config.js ================================================ import json from "@rollup/plugin-json" import includePaths from "rollup-plugin-includepaths" import commonjs from "@rollup/plugin-commonjs" import { babel } from "@rollup/plugin-babel" import nodeResolve from "@rollup/plugin-node-resolve" import { terser } from "rollup-plugin-terser" import { version } from "./package.json" const year = new Date().getFullYear() const banner = `/*\nTrix ${version}\nCopyright © ${year} 37signals, LLC\n */` const plugins = [ json(), includePaths({ paths: [ "src" ], extensions: [ ".js" ] }), nodeResolve({ extensions: [ ".js" ] }), commonjs({ extensions: [ ".js" ] }), babel({ babelHelpers: "bundled" }), ] const defaultConfig = { context: "window", treeshake: false, plugins: plugins, watch: { include: "src/**" } } const terserConfig = terser({ mangle: true, compress: true, format: { comments: function (node, comment) { const text = comment.value const type = comment.type if (type == "comment2") { // multiline comment return /@license|Copyright/.test(text) } }, }, }) const compressedConfig = Object.assign({}, defaultConfig, { plugins: plugins.concat(terserConfig) }) export default [ { input: "src/trix/trix.js", output: [ { name: "Trix", file: "dist/trix.umd.js", format: "umd", banner }, { file: "dist/trix.esm.js", format: "es", banner }, { name: "Trix", file: "action_text-trix/app/assets/javascripts/trix.js", format: "umd", banner }, ], ...defaultConfig, }, { input: "src/trix/trix.js", output: [ { name: "Trix", file: "dist/trix.umd.min.js", format: "umd", banner, sourcemap: true }, { file: "dist/trix.esm.min.js", format: "es", banner, sourcemap: true } ], ...compressedConfig, }, { input: "src/test/test.js", output: { name: "TrixTests", file: "dist/test.js", format: "es", sourcemap: true, banner }, ...defaultConfig, }, { input: "src/inspector/inspector.js", output: { name: "TrixInspector", file: "dist/inspector.js", format: "es", sourcemap: true, banner }, ...defaultConfig, } ] ================================================ FILE: src/inspector/control_element.js ================================================ const KEY_EVENTS = "keydown keypress input".split(" ") const COMPOSITION_EVENTS = "compositionstart compositionupdate compositionend textInput".split(" ") const OBSERVER_OPTIONS = { attributes: true, childList: true, characterData: true, characterDataOldValue: true, subtree: true, } export default class ControlElement { constructor(editorElement) { this.didMutate = this.didMutate.bind(this) this.editorElement = editorElement this.install() } install() { this.createElement() this.logInputEvents() this.logMutations() } uninstall() { this.observer.disconnect() this.element.parentNode.removeChild(this.element) } createElement() { this.element = document.createElement("div") this.element.setAttribute("contenteditable", "") this.element.style.width = getComputedStyle(this.editorElement).width this.element.style.minHeight = "50px" this.element.style.border = "1px solid green" this.editorElement.parentNode.insertBefore(this.element, this.editorElement.nextSibling) } logInputEvents() { KEY_EVENTS.forEach((eventName) => { this.element.addEventListener(eventName, (event) => console.log(`${event.type}: keyCode = ${event.keyCode}`)) }) COMPOSITION_EVENTS.forEach((eventName) => { this.element.addEventListener(eventName, (event) => console.log(`${event.type}: data = ${JSON.stringify(event.data)}`) ) }) } logMutations() { this.observer = new window.MutationObserver(this.didMutate) this.observer.observe(this.element, OBSERVER_OPTIONS) } didMutate(mutations) { console.log(`Mutations (${mutations.length}):`) for (let index = 0; index < mutations.length; index++) { const mutation = mutations[index] console.log(` ${index + 1}. ${mutation.type}:`) switch (mutation.type) { case "characterData": console.log(` oldValue = ${JSON.stringify(mutation.oldValue)}, newValue = ${JSON.stringify(mutation.target.data)}`) break case "childList": Array.from(mutation.addedNodes).forEach((node) => { console.log(` node added ${inspectNode(node)}`) }) Array.from(mutation.removedNodes).forEach((node) => { console.log(` node removed ${inspectNode(node)}`) }) } } } } const inspectNode = function(node) { if (node.data) { return JSON.stringify(node.data) } else { return JSON.stringify(node.outerHTML) } } ================================================ FILE: src/inspector/debugger.js ================================================ /* eslint-disable id-length, no-empty, */ // This file is not included in the main Trix bundle and // should be explicitly required to enable the debugger. const DEBUG_METHODS = { AttachmentEditorController: [ "didClickRemoveButton", "uninstall", ], "Trix.CompositionController": [ "didClickAttachment" ], EditorController: [ "setEditor", "loadDocument", ], "Trix.Level0InputController": [ "elementDidMutate", "events.keydown", "events.keypress", "events.dragstart", "events.dragover", "events.dragend", "events.drop", "events.cut", "events.paste", "events.compositionstart", "events.compositionend", ], "Trix.Level2InputController": [ "elementDidMutate", "events.beforeinput", "events.input", "events.compositionend", ], "Trix.ToolbarController": [ "didClickActionButton", "didClickAttributeButton", "didClickDialogButton", "didKeyDownDialogInput", ] } import { findClosestElementFromNode } from "trix/core/helpers" let errorListeners = [] Trix.Debugger = { addErrorListener(listener) { if (!errorListeners.includes(listener)) { errorListeners.push(listener) } }, removeErrorListener(listener) { errorListeners = errorListeners.filter((l) => l !== listener) }, } const installMethodDebugger = function(className, methodName) { const [ objectName, ...constructorNames ] = className.split(".") const parts = methodName.split(".") const propertyNames = parts.slice(0, parts.length - 1) methodName = parts[parts.length - 1] let object = this[objectName] constructorNames.forEach((constructorName) => { object = object[constructorName] }) object = object.prototype propertyNames.forEach((propertyName) => { object = object[propertyName] }) if (typeof object?.[methodName] === "function") { object[methodName] = wrapFunctionWithErrorHandler(object[methodName]) } else { throw new Error("Can't install on non-function") } } const wrapFunctionWithErrorHandler = function(fn) { const trixDebugWrapper = function() { try { return fn.apply(this, arguments) } catch (error) { reportError(error) throw error } } return trixDebugWrapper } const reportError = function(error) { Trix.Debugger.lastError = error console.error("Trix error!") console.log(error.stack) const { activeElement } = document const editorElement = findClosestElementFromNode(activeElement, { matchingSelector: "trix-editor" }) if (editorElement) { notifyErrorListeners(error, editorElement) } else { console.warn("Can't find element. document.activeElement =", activeElement) } } const notifyErrorListeners = (error, element) => { errorListeners.forEach((listener) => { try { listener(error, element) } catch (error1) {} }) } (function() { console.groupCollapsed("Trix debugger") for (const className in DEBUG_METHODS) { const methodNames = DEBUG_METHODS[className] methodNames.forEach((methodName) => { try { installMethodDebugger(className, methodName) console.log(`✓ ${className}#${methodName}`) } catch (error) { console.warn(`✗ ${className}#${methodName}:`, error.message) } }) } console.groupEnd() })() ================================================ FILE: src/inspector/element.js ================================================ /* eslint-disable id-length, */ import { installDefaultCSSForTagName } from "trix/core/helpers" installDefaultCSSForTagName("trix-inspector", `\ %t { display: block; } %t { position: fixed; background: #fff; border: 1px solid #444; border-radius: 5px; padding: 10px; font-family: sans-serif; font-size: 12px; overflow: auto; word-wrap: break-word; } %t details { margin-bottom: 10px; } %t summary:focus { outline: none; } %t details .panel { padding: 10px; } %t .performance .metrics { margin: 0 0 5px 5px; } %t .selection .characters { margin-top: 10px; } %t .selection .character { display: inline-block; font-size: 8px; font-family: courier, monospace; line-height: 10px; vertical-align: middle; text-align: center; width: 10px; height: 10px; margin: 0 1px 1px 0; border: 1px solid #333; border-radius: 1px; background: #676666; color: #fff; } %t .selection .character.selected { background: yellow; color: #000; }\ `) export default class TrixInspector extends HTMLElement { connectedCallback() { this.editorElement = document.querySelector(`trix-editor[trix-id='${this.dataset.trixId}']`) this.views = this.createViews() this.views.forEach((view) => { view.render() this.appendChild(view.element) }) this.reposition() this.resizeHandler = this.reposition.bind(this) addEventListener("resize", this.resizeHandler) } disconnectedCallback() { removeEventListener("resize", this.resizeHandler) } createViews() { const views = Trix.Inspector.views.map((View) => new View(this.editorElement)) return views.sort((a, b) => a.title.toLowerCase() > b.title.toLowerCase()) } reposition() { const { top, right } = this.editorElement.getBoundingClientRect() this.style.top = `${top}px` this.style.left = `${right + 10}px` this.style.maxWidth = `${window.innerWidth - right - 40}px` this.style.maxHeight = `${window.innerHeight - top - 30}px` } } window.customElements.define("trix-inspector", TrixInspector) ================================================ FILE: src/inspector/global.js ================================================ window.Trix.Inspector = { views: [], registerView(constructor) { return this.views.push(constructor) }, install(editorElement) { this.editorElement = editorElement const element = document.createElement("trix-inspector") element.dataset.trixId = this.editorElement.trixId return document.body.appendChild(element) }, } ================================================ FILE: src/inspector/inspector.js ================================================ import "inspector/element" import "inspector/global" import "inspector/templates" import "inspector/control_element" import "inspector/views/debug_view" import "inspector/views/document_view" import "inspector/views/performance_view" import "inspector/views/render_view" import "inspector/views/selection_view" import "inspector/views/undo_view" ================================================ FILE: src/inspector/templates/debug.js ================================================ if (!window.JST) window.JST = {} window.JST["trix/inspector/templates/debug"] = function() { return `

` } ================================================ FILE: src/inspector/templates/document.js ================================================ if (!window.JST) window.JST = {} window.JST["trix/inspector/templates/document"] = function() { const details = this.document.getBlocks().map((block, index) => { const { text } = block const pieces = text.pieceList.toArray() return `
Block ${block.id}, Index: ${index}
Attributes: ${JSON.stringify(block.attributes)}
HTML Attributes: ${JSON.stringify(block.htmlAttributes)}
Text: ${text.id}, Pieces: ${pieces.length}, Length: ${text.getLength()}
${piecePartials(pieces).join("\n")}
` }) return details.join("\n") } const piecePartials = (pieces) => pieces.map((piece, index) =>`
Piece ${piece.id}, Index: ${index}
Attributes: ${JSON.stringify(piece.attributes)}
${JSON.stringify(piece.toString())}
`) ================================================ FILE: src/inspector/templates/performance.js ================================================ if (!window.JST) window.JST = {} window.JST["trix/inspector/templates/performance"] = function() { return Object.keys(this.data).map((name) => { const data = this.data[name] return dataMetrics(name, data, this.round) }).join("\n") } const dataMetrics = function(name, data, round) { let item = `${name} (${data.calls})
` if (data.calls > 0) { item += `
Mean: ${round(data.mean)}ms
Max: ${round(data.max)}ms
Last: ${round(data.last)}ms
` return item } } ================================================ FILE: src/inspector/templates/render.js ================================================ if (!window.JST) window.JST = {} window.JST["trix/inspector/templates/render"] = () => `Syncs: ${this.syncCount}` ================================================ FILE: src/inspector/templates/selection.js ================================================ if (!window.JST) window.JST = {} window.JST["trix/inspector/templates/selection"] = function() { return `Location range: [${this.locationRange[0].index}:${this.locationRange[0].offset}, ${this.locationRange[1].index}:${this.locationRange[1].offset}] ${charSpans(this.characters).join("\n")}` } const charSpans = (characters) => Array.from(characters).map( (char) => `${char.string}` ) ================================================ FILE: src/inspector/templates/undo.js ================================================ if (!window.JST) window.JST = {} window.JST["trix/inspector/templates/undo"] = () => `

Undo stack

    ${entryList(this.undoEntries)}

Redo stack

    ${entryList(this.redoEntries)}
` const entryList = (entries) => entries.map((entry) => `
  • ${entry.description} ${JSON.stringify({ selectedRange: entry.snapshot.selectedRange, context: entry.context, })}
  • `) ================================================ FILE: src/inspector/templates.js ================================================ import "inspector/templates/debug" import "inspector/templates/document" import "inspector/templates/performance" import "inspector/templates/render" import "inspector/templates/selection" import "inspector/templates/undo" ================================================ FILE: src/inspector/view.js ================================================ import { handleEvent } from "trix/core/helpers" export default class View { constructor(editorElement) { this.editorElement = editorElement this.editorController = this.editorElement.editorController this.editor = this.editorElement.editor this.compositionController = this.editorController.compositionController this.composition = this.editorController.composition this.element = document.createElement("details") if (this.getSetting("open") === "true") { this.element.open = true } this.element.classList.add(this.constructor.template) this.titleElement = document.createElement("summary") this.element.appendChild(this.titleElement) this.panelElement = document.createElement("div") this.panelElement.classList.add("panel") this.element.appendChild(this.panelElement) this.element.addEventListener("toggle", (event) => { if (event.target === this.element) { return this.didToggle() } }) if (this.events) { this.installEventHandlers() } } installEventHandlers() { for (const eventName in this.events) { const handler = this.events[eventName] const callback = (event) => { requestAnimationFrame(() => { handler.call(this, event) }) } handleEvent(eventName, { onElement: this.editorElement, withCallback: callback }) } } didToggle(event) { this.saveSetting("open", this.isOpen()) return this.render() } isOpen() { return this.element.hasAttribute("open") } getTitle() { return this.title || "" } render() { this.renderTitle() if (this.isOpen()) { this.panelElement.innerHTML = window.JST[`trix/inspector/templates/${this.constructor.template}`].apply(this) } } renderTitle() { this.titleElement.innerHTML = this.getTitle() } getSetting(key) { key = this.getSettingsKey(key) return window.sessionStorage?.[key] } saveSetting(key, value) { key = this.getSettingsKey(key) if (window.sessionStorage) { window.sessionStorage[key] = value } } getSettingsKey(key) { return `trix/inspector/${this.template}/${key}` } get title() { return this.constructor.title } get template() { return this.constructor.template } get events() { return this.constructor.events } } ================================================ FILE: src/inspector/views/debug_view.js ================================================ import View from "inspector/view" import { handleEvent } from "trix/core/helpers" class DebugView extends View { static title = "Debug" static template = "debug" constructor() { super(...arguments) this.didToggleViewCaching = this.didToggleViewCaching.bind(this) this.didClickRenderButton = this.didClickRenderButton.bind(this) this.didClickParseButton = this.didClickParseButton.bind(this) this.didToggleControlElement = this.didToggleControlElement.bind(this) handleEvent("change", { onElement: this.element, matchingSelector: "input[name=viewCaching]", withCallback: this.didToggleViewCaching, }) handleEvent("click", { onElement: this.element, matchingSelector: "button[data-action=render]", withCallback: this.didClickRenderButton, }) handleEvent("click", { onElement: this.element, matchingSelector: "button[data-action=parse]", withCallback: this.didClickParseButton, }) handleEvent("change", { onElement: this.element, matchingSelector: "input[name=controlElement]", withCallback: this.didToggleControlElement, }) } didToggleViewCaching({ target }) { if (target.checked) { return this.compositionController.enableViewCaching() } else { return this.compositionController.disableViewCaching() } } didClickRenderButton() { return this.editorController.render() } didClickParseButton() { return this.editorController.reparse() } didToggleControlElement({ target }) { if (target.checked) { this.control = new Trix.Inspector.ControlElement(this.editorElement) } else { this.control?.uninstall() this.control = null } } } Trix.Inspector.registerView(DebugView) ================================================ FILE: src/inspector/views/document_view.js ================================================ import View from "inspector/view" class DocumentView extends View { static title = "Document" static template = "document" static events = { "trix-change": function() { return this.render() }, } render() { this.document = this.editor.getDocument() return super.render(...arguments) } } Trix.Inspector.registerView(DocumentView) ================================================ FILE: src/inspector/views/performance_view.js ================================================ import View from "inspector/view" const now = window.performance?.now ? () => performance.now() : () => new Date().getTime() class PerformanceView extends View { static title = "Performance" static template = "performance" constructor() { super(...arguments) this.documentView = this.compositionController.documentView this.data = {} this.track("documentView.render") this.track("documentView.sync") this.track("documentView.garbageCollectCachedViews") this.track("composition.replaceHTML") this.render() } track(methodPath) { this.data[methodPath] = { calls: 0, total: 0, mean: 0, max: 0, last: 0 } const parts = methodPath.split(".") const propertyNames = parts.slice(0, parts.length - 1) const methodName = parts[parts.length - 1] let object = this propertyNames.forEach((propertyName) => { object = object[propertyName] }) const original = object[methodName] object[methodName] = function() { const started = now() const result = original.apply(object, arguments) const timing = now() - started this.record(methodPath, timing) return result }.bind(this) } record(methodPath, timing) { const data = this.data[methodPath] data.calls += 1 data.total += timing data.mean = data.total / data.calls if (timing > data.max) { data.max = timing } data.last = timing return this.render() } round(ms) { return Math.round(ms * 1000) / 1000 } } Trix.Inspector.registerView(PerformanceView) ================================================ FILE: src/inspector/views/render_view.js ================================================ import View from "inspector/view" export default class RenderView extends View { static title = "Renders" static template = "render" static events = { "trix-render": function() { this.renderCount++ return this.render() }, "trix-sync": function() { this.syncCount++ return this.render() }, } constructor() { super(...arguments) this.renderCount = 0 this.syncCount = 0 } getTitle() { return `${this.title} (${this.renderCount})` } } Trix.Inspector.registerView(RenderView) ================================================ FILE: src/inspector/views/selection_view.js ================================================ import View from "inspector/view" import UTF16String from "trix/core/utilities/utf16_string" class SelectionView extends View { static title = "Selection" static template = "selection" static events = { "trix-selection-change": function() { return this.render() }, "trix-render": function() { return this.render() }, } render() { this.document = this.editor.getDocument() this.range = this.editor.getSelectedRange() this.locationRange = this.composition.getLocationRange() this.characters = this.getCharacters() return super.render(...arguments) } getCharacters() { const chars = [] const utf16string = UTF16String.box(this.document.toString()) const rangeIsExpanded = this.range[0] !== this.range[1] let position = 0 while (position < utf16string.length) { let string = utf16string.charAt(position).toString() if (string === "\n") { string = "⏎" } const selected = rangeIsExpanded && position >= this.range[0] && position < this.range[1] chars.push({ string, selected }) position++ } return chars } getTitle() { return `${this.title} (${this.range.join()})` } } Trix.Inspector.registerView(SelectionView) ================================================ FILE: src/inspector/views/undo_view.js ================================================ import View from "inspector/view" class UndoView extends View { static title = "Undo" static template = "undo" static events = { "trix-change": function() { return this.render() }, } render() { this.undoEntries = this.editor.undoManager.undoEntries this.redoEntries = this.editor.undoManager.redoEntries return super.render(...arguments) } } Trix.Inspector.registerView(UndoView) ================================================ FILE: src/inspector/watchdog/deserializer.js ================================================ export default class Deserializer { constructor(document, snapshot) { this.document = document this.snapshot = snapshot this.tree = this.snapshot.tree this.selection = this.snapshot.selection this.deserializeTree() this.deserializeSelection() } deserializeTree() { this.nodes = {} this.element = this.deserializeNode(this.tree) } deserializeNode(serializedNode) { let node switch (serializedNode.name) { case "#text": node = this.deserializeTextNode(serializedNode) break case "#comment": node = this.deserializeComment(serializedNode) break default: node = this.deserializeElement(serializedNode) break } this.nodes[serializedNode.id] = node return node } deserializeTextNode({ value }) { return this.document.createTextNode(value) } deserializeComment({ value }) { return this.document.createComment(value) } deserializeChildren(serializedNode) { const children = serializedNode.children ? Array.from(serializedNode.children) : [] return children.map((child) => this.deserializeNode(child)) } deserializeElement(serializedNode) { const node = this.document.createElement(serializedNode.name) const object = serializedNode.attributes ? serializedNode.attributes : {} for (const name in object) { const value = object[name] node.setAttribute(name, value) } while (node.lastChild) { node.removeChild(node.lastChild) } this.deserializeChildren(serializedNode).forEach((childNode) => { node.appendChild(childNode) }) return node } deserializeSelection() { if (!this.selection) return const { start, end } = this.selection const startContainer = this.nodes[start.id] const endContainer = this.nodes[end.id] this.range = this.document.createRange() this.range.setStart(startContainer, start.offset) this.range.setEnd(endContainer, end.offset) return this.range } getElement() { return this.element } getRange() { return this.range } } ================================================ FILE: src/inspector/watchdog/player.js ================================================ import "inspector/watchdog/recording" export default class Player { constructor(recording) { this.tick = this.tick.bind(this) this.recording = recording this.playing = false this.index = -1 this.length = this.recording.getFrameCount() } play() { if (this.playing) return if (this.hasEnded()) { this.index = -1 } this.playing = true this.delegate?.playerDidStartPlaying?.() return this.tick() } tick() { if (this.hasEnded()) { return this.stop() } else { this.seek(this.index + 1) const duration = this.getTimeToNextFrame() this.timeout = setTimeout(this.tick, duration) } } seek(index) { const previousIndex = this.index if (index < 0) { this.index = 0 } else if (index >= this.length) { this.index = this.length - 1 } else { this.index = index } if (this.index !== previousIndex) { return this.delegate?.playerDidSeekToIndex?.(index) } } stop() { if (!this.playing) return clearTimeout(this.timeout) this.timeout = null this.playing = false return this.delegate?.playerDidStopPlaying?.() } isPlaying() { return this.playing } hasEnded() { return this.index >= this.length - 1 } getSnapshot() { return this.recording.getSnapshotAtFrameIndex(this.index) } getEvents() { return this.recording.getEventsUpToFrameIndex(this.index) } getTimeToNextFrame() { const current = this.recording.getTimestampAtFrameIndex(this.index) const next = this.recording.getTimestampAtFrameIndex(this.index + 1) const duration = current && next ? next - current : 0 return Math.min(duration, 500) } } ================================================ FILE: src/inspector/watchdog/player_controller.js ================================================ import Player from "inspector/watchdog/player" import PlayerView from "inspector/watchdog/player_view" export default class PlayerController { constructor(element, recording) { this.element = element this.recording = recording this.player = new Player(this.recording) this.player.delegate = this this.view = new PlayerView(this.element) this.view.delegate = this this.view.setLength(this.player.length) this.player.seek(0) } play() { return this.player.play() } stop() { return this.player.stop() } playerViewDidClickPlayButton() { if (this.player.isPlaying()) { return this.player.stop() } else { return this.player.play() } } playerViewDidChangeSliderValue(value) { return this.player.seek(value) } playerDidSeekToIndex(index) { this.view.setIndex(index) const snapshot = this.player.getSnapshot(index) if (snapshot) { this.view.renderSnapshot(snapshot) } const events = this.player.getEvents(index) if (events) { return this.view.renderEvents(events) } } playerDidStartPlaying() { return this.view.playerDidStartPlaying() } playerDidStopPlaying() { return this.view.playerDidStopPlaying() } } ================================================ FILE: src/inspector/watchdog/player_element.js ================================================ import { installDefaultCSSForTagName } from "trix/core/helpers" import Recording from "inspector/watchdog/recording" import PlayerController from "inspector/watchdog/player_controller" installDefaultCSSForTagName("trix-watchdog-player", `\ %t > div { display: -webkit-flex; display: flex; font-size: 14px; margin: 10px 0 } %t > div > button { width: 65px } %t > div > input { width: 100%; -webkit-align-self: stretch; align-self: stretch; margin: 0 20px } %t > div > span { display: inline-block; text-align: center; width: 110px }\ `) class PlayerElement extends HTMLElement { static get observedAttributes() { return [ "src" ] } connectedCallback() { const url = this.getAttribute("src") if (url) { return this.fetchRecordingAtURL(url) } } attributeChangedCallback(attributeName, oldValue, newValue) { if (attributeName === "src") { return this.fetchRecordingAtURL(newValue) } } fetchRecordingAtURL(url) { this.activeRequest?.abort() this.activeRequest = new XMLHttpRequest() this.activeRequest.open("GET", url) this.activeRequest.send() this.activeRequest.onload = () => { const json = this.activeRequest.responseText this.activeRequest = null const recording = Recording.fromJSON(JSON.parse(json)) return this.loadRecording(recording) } } loadRecording(recording) { this.controller = new PlayerController(this, recording) } } window.customElements.define("trix-watchdog-player", PlayerElement) ================================================ FILE: src/inspector/watchdog/player_view.js ================================================ import Deserializer from "inspector/watchdog/deserializer" import View from "../view" const clear = (element) => { while (element.lastChild) { element.removeChild(element.lastChild) } } const render = (element, ...contents) => { clear(element) contents.forEach((content) => element.appendChild(content)) } const select = (document, range) => { if (!range) return const selection = window.getSelection() selection.removeAllRanges() selection.addRange(range) } export default class PlayerView extends View { static documentClassName = "trix-watchdog-player" static playingClassName = "trix-watchdog-player-playing" constructor(element) { super(...arguments) this.frameDidLoadDefaultDocument = this.frameDidLoadDefaultDocument.bind(this) this.frameDidLoadStylesheet = this.frameDidLoadStylesheet.bind(this) this.frameDidLoseFocus = this.frameDidLoseFocus.bind(this) this.didClickPlayButton = this.didClickPlayButton.bind(this) this.didChangeSliderValue = this.didChangeSliderValue.bind(this) this.updateFrame = this.updateFrame.bind(this) this.element = element this.frame = document.createElement("iframe") this.frame.style.border = "none" this.frame.style.width = "100%" this.frame.onload = this.frameDidLoadDefaultDocument this.frame.onblur = this.frameDidLoseFocus const controlsContainer = document.createElement("div") this.playButton = document.createElement("button") this.playButton.textContent = "Play" this.playButton.onclick = this.didClickPlayButton this.slider = document.createElement("input") this.slider.type = "range" this.slider.oninput = this.didChangeSliderValue this.indexLabel = document.createElement("span") const logContainer = document.createElement("div") this.log = document.createElement("textarea") this.log.setAttribute("readonly", "") this.log.rows = 4 render(controlsContainer, this.playButton, this.slider, this.indexLabel) render(logContainer, this.log) render(this.element, this.frame, controlsContainer, logContainer) this.setIndex(0) } renderSnapshot(snapshot) { if (this.body) { const { element, range } = this.deserializeSnapshot(snapshot) render(this.body, element) select(this.document, range) return this.updateFrame() } else { this.snapshot = snapshot } } renderEvents(events) { const renderedEvents = events.slice().reverse().map((event, index) => { return this.renderEvent(event, index) }) this.log.innerText = renderedEvents.join("\n") } setIndex(index) { this.slider.value = index this.indexLabel.textContent = `Frame ${index}` } setLength(length) { this.slider.max = length - 1 } playerDidStartPlaying() { this.element.classList.add(this.constructor.playingClassName) this.playButton.textContent = "Pause" } playerDidStopPlaying() { this.element.classList.remove(this.constructor.playingClassName) this.playButton.textContent = "Play" } frameDidLoadDefaultDocument() { this.document = this.frame.contentDocument this.document.documentElement.classList.add(this.constructor.documentClassName) this.document.head.innerHTML = document.head.innerHTML Array.from(this.document.head.querySelectorAll("link[rel=stylesheet]")).forEach((stylesheet) => { stylesheet.onload = this.frameDidLoadStylesheet }) this.body = this.document.body this.body.style.cssText = "margin: 0; padding: 0" this.body.onkeydown = (event) => event.preventDefault() if (this.snapshot) { this.renderSnapshot(this.snapshot) this.snapshot = null } } frameDidLoadStylesheet() { return this.updateFrame() } frameDidLoseFocus() { if (this.element.classList.contains(this.constructor.playingClassName)) { return requestAnimationFrame(this.updateFrame) } } didClickPlayButton() { return this.delegate?.playerViewDidClickPlayButton?.() } didChangeSliderValue() { const value = parseInt(this.slider.value, 10) return this.delegate?.playerViewDidChangeSliderValue?.(value) } renderEvent(event, index) { let description, key switch (event.type) { case "input": description = "Browser input event received" break case "keypress": key = event.character || event.charCode || event.keyCode description = `Key pressed: ${JSON.stringify(key)}` break case "log": description = event.message break case "snapshot": description = "DOM update" } return `[${index}] ${description}` } deserializeSnapshot(snapshot) { const deserializer = new Deserializer(this.document, snapshot) return { element: deserializer.getElement(), range: deserializer.getRange(), } } updateFrame() { this.frame.style.height = 0 this.frame.style.height = this.body.scrollHeight + "px" this.frame.focus() return this.frame.contentWindow.focus() } } ================================================ FILE: src/inspector/watchdog/recorder.js ================================================ import Recording from "inspector/watchdog/recording" import Serializer from "inspector/watchdog/serializer" export default class Recorder { constructor(element, { snapshotLimit } = {}) { this.recordSnapshotDuringNextAnimationFrame = this.recordSnapshotDuringNextAnimationFrame.bind(this) this.handleEvent = this.handleEvent.bind(this) this.element = element this.snapshotLimit = snapshotLimit this.recording = new Recording() } start() { if (this.started) return this.installMutationObserver() this.installEventListeners() this.recordSnapshot() this.started = true } stop() { if (!this.started) return this.uninstallMutationObserver() this.uninstallEventListeners() this.started = false } log(message) { return this.recording.recordEvent({ type: "log", message }) } installMutationObserver() { this.mutationObserver = new MutationObserver(this.recordSnapshotDuringNextAnimationFrame) return this.mutationObserver.observe(this.element, { attributes: true, characterData: true, childList: true, subtree: true, }) } uninstallMutationObserver() { this.mutationObserver.disconnect() this.mutationObserver = null } recordSnapshotDuringNextAnimationFrame() { if (!this.animationFrameRequest) { this.animationFrameRequest = requestAnimationFrame(() => { this.animationFrameRequest = null return this.recordSnapshot() }) } return this.animationFrameRequest } installEventListeners() { this.element.addEventListener("input", this.handleEvent, true) this.element.addEventListener("keypress", this.handleEvent, true) return document.addEventListener("selectionchange", this.handleEvent, true) } uninstallEventListeners() { this.element.removeEventListener("input", this.handleEvent, true) this.element.removeEventListener("keypress", this.handleEvent, true) return document.removeEventListener("selectionchange", this.handleEvent, true) } handleEvent(event) { switch (event.type) { case "input": return this.recordInputEvent(event) case "keypress": return this.recordKeypressEvent(event) case "selectionchange": return this.recordSnapshotDuringNextAnimationFrame() } } recordInputEvent(event) { return this.recording.recordEvent({ type: "input" }) } recordKeypressEvent(event) { return this.recording.recordEvent({ type: "keypress", altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey, keyCode: event.keyCode, charCode: event.charCode, character: characterFromKeyboardEvent(event), }) } recordSnapshot() { this.recording.recordSnapshot(this.getSnapshot()) if (this.snapshotLimit != null) { return this.recording.truncateToSnapshotCount(this.snapshotLimit) } } getSnapshot() { const serializer = new Serializer(this.element) return serializer.getSnapshot() } } const characterFromKeyboardEvent = function(event) { if (event.which === null) { return String.fromCharCode(event.keyCode) } else if (event.which !== 0 && event.charCode !== 0) { return String.fromCharCode(event.charCode) } } ================================================ FILE: src/inspector/watchdog/recording.js ================================================ export default class Recording { static fromJSON({ snapshots, frames }) { return new this(snapshots, frames) } constructor(snapshots = [], frames = []) { this.snapshots = snapshots this.frames = frames } recordSnapshot(snapshot) { const snapshotJSON = JSON.stringify(snapshot) if (snapshotJSON !== this.lastSnapshotJSON) { this.lastSnapshotJSON = snapshotJSON this.snapshots.push(snapshot) return this.recordEvent({ type: "snapshot" }) } } recordEvent(event) { const frame = [ this.getTimestamp(), this.snapshots.length - 1, event ] return this.frames.push(frame) } getSnapshotAtIndex(index) { if (index >= 0) { return this.snapshots[index] } } getSnapshotAtFrameIndex(frameIndex) { const snapshotIndex = this.getSnapshotIndexAtFrameIndex(frameIndex) return this.getSnapshotAtIndex(snapshotIndex) } getTimestampAtFrameIndex(index) { return this.frames[index]?.[0] } getSnapshotIndexAtFrameIndex(index) { return this.frames[index]?.[1] } getEventAtFrameIndex(index) { return this.frames[index]?.[2] } getEventsUpToFrameIndex(index) { return this.frames.slice(0, index + 1).map((frame) => frame[2]) } getFrameCount() { return this.frames.length } getTimestamp() { return new Date().getTime() } truncateToSnapshotCount(snapshotCount) { const offset = this.snapshots.length - snapshotCount if (offset < 0) return const { frames } = this this.frames = frames.map(([ timestamp, index, event ]) => { if (index >= offset) { return [ timestamp, index - offset, event ] } }).filter(frame => frame) this.snapshots = this.snapshots.slice(offset) } toJSON() { return { snapshots: this.snapshots, frames: this.frames } } } ================================================ FILE: src/inspector/watchdog/serializer.js ================================================ export default class Serializer { constructor(element) { this.element = element this.id = 0 this.serializeTree() this.serializeSelection() } serializeTree() { this.ids = new Map() this.tree = this.serializeNode(this.element) } serializeNode(node) { const object = { id: ++this.id, name: node.nodeName } this.ids.set(node, object.id) switch (node.nodeType) { case Node.ELEMENT_NODE: this.serializeElementToObject(node, object) this.serializeElementChildrenToObject(node, object) break case Node.TEXT_NODE: case Node.COMMENT_NODE: this.serializeNodeValueToObject(node, object) break } return object } serializeElementToObject(node, object) { const attributes = {} let hasAttributes = false Array.from(node.attributes).forEach(({ name }) => { if (node.hasAttribute(name)) { let value = node.getAttribute(name) if (name === "src" && value.slice(0, 5) === "data:") { value = "data:" } attributes[name] = value hasAttributes = true } }) if (hasAttributes) { object.attributes = attributes } } serializeElementChildrenToObject(node, object) { if (node.childNodes.length) { object.children = Array.from(node.childNodes).map((childNode) => this.serializeNode(childNode)) } } serializeNodeValueToObject(node, object) { object.value = node.nodeValue } serializeSelection() { const selection = window.getSelection() if (selection.rangeCount <= 0) return const range = selection.getRangeAt(0) const startId = this.ids.get(range?.startContainer) const endId = this.ids.get(range?.endContainer) if (startId && endId) { this.selection = { start: { id: startId, offset: range.startOffset }, end: { id: endId, offset: range.endOffset }, } } } getSnapshot() { return { tree: this.tree, selection: this.selection } } } ================================================ FILE: src/inspector/watchdog.js ================================================ import "inspector/watchdog/recorder" import "inspector/watchdog/player_element" Trix.Watchdog = {} ================================================ FILE: src/test/system/accessibility_test.js ================================================ import { assert, insertImageAttachment, skipIf, test, testGroup, triggerEvent } from "test/test_helper" import { delay } from "test/test_helpers/timing_helpers" import TrixEditorElement from "trix/elements/trix_editor_element" testGroup("Accessibility attributes", { template: "editor_default_aria_label" }, () => { test("sets the role to textbox", () => { const editor = document.getElementById("editor-without-labels") assert.equal(editor.getAttribute("role"), "textbox") }) test("reads img[alt] from Attachment attributes", async () => { const element = getEditorElement() element.addEventListener("trix-attachment-add", (event) => event.attachment.setAttributes({ alt: "some alt text" })) insertImageAttachment() await delay(20) const image = element.querySelector("img") assert.equal("some alt text", image.getAttribute("alt"), "sets [alt] from Attachment attribute") }) skipIf(TrixEditorElement.formAssociated, "does not set aria-label when the element has no