Fix vertical content jumps in the BlockNote editor on selection
BlockNote 0.51 puts the className we pass to <BlockNoteView>
(`block-note-editor-container`) onto BOTH the outer `.bn-container`
wrapper AND an inner wrapper that does NOT carry `.bn-container`. With
the previous selector matching by class name alone, every rule cascaded
onto both nesting levels — most importantly `display: flex`,
`flex-direction: column-reverse` and `gap: 10px`.
Two flex layouts stacked one inside the other meant that whenever the
side menu / drag handle plugin views re-rendered (which happens every
time the selection moves or the mouse leaves the editor), both layout
calcs ran and the inner wrapper's gap shifted the visible content by a
few pixels.
Tightening the selector to `.block-note-editor-container.bn-container`
restricts the rules to the outer wrapper only; the inner wrapper falls
back to defaults (`display: block`, no gap) and stops contributing to
the layout.
Refs https://community.openproject.org/wp/STC-779
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
The view-helper migration introduced by the parent PR now covers the
remaining mailer surfaces: UserMailer (`message_posted`, `news_added`,
`news_comment_added`), ProjectMailer (`project_created`),
ProjectArtifactsMailer (`creation_wizard_submitted`), MemberMailer
(`added_project`, `updated_project`, `updated_global`), AnnouncementMailer
(`announce`), DocumentsMailer (`document_added`), and the shared mailer
layout (`localized_emails_header`, `localized_emails_footer`).
Sites drop the `static_html: true` / `only_path: false` / `plain_text: true`
boilerplate; `render_mode:` pinning lives in the helper.
The layout previously called `OpenProject::TextFormatting::Renderer.format_text`
directly, bypassing the helper layer. The empty visibility cache (no
current_user-scoped preload at layout time) is handled by the existing
fallback in `LinkHandlers::WorkPackages#text_only?` — covered by a new
sanity spec in `user_mailer_spec.rb` that exercises the header path with
a WP reference and asserts plain-text formatted_id rendering.
Per-bucket regression coverage added: absolute-URL and formatted_id
assertions across both classic and semantic identifier modes, mirroring
the WorkPackageMailer spec pattern.
Park BlockNoteEditorBrowserActions next to BlockNoteEditorInput in
spec/support/form_fields/primerized/. spec/support/**/*.rb is autoloaded
by rails_helper, so the spec file just `include`s the qualified module
name. Keeps raw-driver concerns (DOM Range selection, paste
ClipboardEvent dispatch, W3C-action Delete) out of the high-level page
object — that one stays focused on semantic actions like paste_links
and attach_file — and avoids the test-file fatigue of a 50-line helper
module inside the describe scope.
The two delete-path tests and the multi-inline-node paste test each
inlined a slab of `page.execute_script` to drive the editor's
contenteditable inside its shadow root, with significant duplication
around DOM Range setup and ClipboardEvent construction.
Pull these into a `BlockNoteEditorBrowserActions` module:
- `select_text_in_external_link(start_offset:, end_offset:)` — selects
a substring of the first link's text node, with String-slicing
semantics for the offsets (negatives count from the end).
- `send_forward_delete` — forward Delete via the W3C actions API,
needed because Capybara's send_keys does not reach PM's editable in
this setup.
- `paste_clipboard_into(element, html:, plain:)` — fires a paste
ClipboardEvent on the given editor element.
The deletion-test comments are also tightened to describe the current
invariant (apply gate reseats the widget on any range deletion) rather
than the pre-rebuild mapping mechanics they used to enumerate.
Locks in the invariant that editing inside an existing link routes
through decoration mapping rather than a rebuild: the ReplaceStep
slice carries no link mark, so the apply gate maps the existing
widget set instead of recomputing it. The screen-reader hint must
shrink with the link run from the right and stay anchored to the
new tail, leaving exactly one hint on the surviving link.
The orphan-widget concern Copilot raised on the apply gate isn't
reachable. Forcing the gate's slice check off and deleting a whole
linked range still produces a clean DOM — PM's WidgetType.map
resolves the widget's position with assoc=-1 (which the widget's
side: -1 enforces), finds the anchor inside the deleted content,
and reports deleted: true. PM drops the decoration on its own.
Replacing the "Limitation" JSDoc paragraph (which described a
mental model that doesn't match WidgetType.map's rules) with a
feature spec that exercises a single-tx delete via DOM Range +
W3C actions Delete. The spec fails if side, the apply gate, or
buildDecorations ever stops preserving the invariant — serving
the disclosure purpose the JSDoc tried to, but mechanically.
The sr-only "Open link in a new tab" hint becomes part of the link's
computed accessible name. Without a separator between the link text and
the hint, AT can announce them as a single concatenated word — descendant
text-node concatenation isn't guaranteed to insert whitespace, especially
in contenteditable. Prefix the widget text with an NBSP and relax the
feature spec to use `include` so the separator detail stays an
implementation concern of the extension.
VoiceOver and NVDA do not announce aria-describedby on links inside
contenteditable regions — both screen readers switch to edit mode and
ignore supplementary ARIA there. Even moving the attribute to the <a>
itself does not help.
Add an ExternalLinkA11yExtension that injects a sr-only,
contenteditable="false" span as a child of each external <a> via a
ProseMirror widget decoration wrapped in the link mark. The span text
becomes part of the link's accessible name, which screen readers
announce in every mode.
- Widget uses marks: [linkMark] + side: -1 so it renders inside the
anchor and stays attached to the preceding link run on insertion.
- sameLinkContinues coalesces runs split across inline nodes (e.g.
link text with a nested bold mark) so each link gets exactly one hint.
- readDescription warns once if #open-blank-target-link-description is
missing, surfacing silent empty-hint regressions.
- Extracts isHrefExternal from isLinkExternal so the extension can
check ProseMirror mark attrs (URL strings) directly.
- Decorations never mutate the document model — no DOMObserver
mutation loop, no Yjs/collaboration side effects, no persistence.
References https://community.openproject.org/wp/73721
* Fix GitHub/NoTitleAttribute, LinkHasHref errors
- Replaces `title` attribute with `aria-label` for interactive elements.
- Removes `title` from non-interactive elements.
- Converts `<a>` tags without proper `href` to `<button>` elements,
using Primer `Button`/`IconButton` where possible.
# Conflicts:
# app/views/custom_fields/_custom_options.html.erb
# spec/features/admin/custom_fields/shared_custom_field_expectations.rb
# spec/features/admin/custom_fields/work_packages/list_spec.rb
* Fix Autocomplete missing errors
* Fix GitHub/NoPositiveTabIndex errors
Removes all positive `tabindex` values.
* Fix Rails/LinkToBlank errors
* Replace toast with Primer Banner on LDAP form
* Add frozen_string_literal
* Ignore erb lint for deprecated files
* Fix linting errors in repository module
* Fix linting errors in budgets and custom actions
* Fix linting errors in member form and 2fa
* Fix linting errors in mcost types and wiki help and storages
* Fix linting errors in multi select filters, ifc viewer, and unsupported browser banner
* Fix failing spec
* Use Primer banner instead of op-toast where ever it is possible
* Use octicon instead of op_icon
* Fix failing tests
* Use no-decoration-on-hover for button links and change the button with only an icon to primer icon button
* Keep webhook response modal activation selector class-based
* use icon button for edit of hourly rate
---------
Co-authored-by: Behrokh Satarnejad <b.satarnejad@openproject.com>
In 0.0.24 a callback was added that removes the whole search input from
the DOM / document when a blur event (e.g. clicking anywhere else)
occurs. This broke the capybara tests, since they somehow triggered that
blur and then the whole input was removed from the DOM before the tests
were finished testing it.
Therefore now all interaction of with the work package search input is
done via js whithout any interruptions that cause a blur event.
I am not happy about this solution and asked to relax that blur
requirement to e.g. only remove the search when the document / browser
tab is closed, but this was refused. So this is the only solution to
keep the tests in at all.
https://community.openproject.org/wp/69706
Improve visual representation of inline macros, by ensuring
they all use the same CSS attributes and have a reduced vertical padding,
so they don't overlap as much.
We also improved the vertical alignment of the leading icon for these macros.
As a sidefind, we replaced a handcrafted version of the InlineMessage with the
existing InlineMessage component. This also improved alignment of the icon.
Lint/RedundantCopDisableDirective flagged the inline Style/RescueModifier
disable as unnecessary on CI. Locally the cop fires and the disable is
needed, so the constructs disagree across environments. Rewrite the
ensure-block cleanup to use a rescue-in-do form instead — no modifier,
no directive, same behavior.
Use BlockNote's createExtension() with prosemirrorPlugins instead of
TipTap's Extension.create() via the internal _tiptapOptions escape
hatch. The BlockNote team has clarified that _tiptapOptions is not
part of the public API and should not be relied on.
Functionally identical — same ProseMirror plugin, same mousedown
interception — just wrapped in the supported BlockNote primitive.
Handle Text node event targets by normalizing to parentElement
before calling closest('a'). Guard against clicks on anchors
outside the editor content with view.dom.contains(). Replace
brittle sleep with rspec-wait polling for window count.
TipTap's Link extension handles clicks via ProseMirror's mouseup-based
handleClick, which fires before any DOM click event. The Stimulus
controller's click interception couldn't prevent TipTap from also
opening a window, resulting in two windows on every external link click.
Replace the Stimulus click interception with a TipTap extension that
uses handleDOMEvents.mousedown. Returning true from mousedown prevents
ProseMirror from creating its internal MouseDown tracker, so the entire
handleClick chain never fires. Only our redirect window opens.
The extension is conditionally registered via _tiptapOptions only when
external link capture is enabled. When disabled, TipTap's default
openOnClick behavior handles link clicks natively.
ProseMirror's internal DOMObserver re-parses and re-renders any node
whose attributes change, creating infinite loops when the body-level
ExternalLinksController writes target, rel, aria-describedby, or
rewrites href on links inside the editor.
Instead of modifying the DOM, a standalone ProseMirrorExternalLinksController
intercepts clicks on external links and routes them through
/external_redirect via window.open. The document model retains original
URLs, Yjs collaboration is unaffected, and no re-render loops occur.
TipTap's Link extension already renders target="_blank" and
rel="noopener noreferrer nofollow" from its mark schema defaults,
so those attributes are handled natively by ProseMirror.
Shared link utilities (isLinkExternal, shouldProcessLink,
buildRedirectUrl) are extracted into link-handling helpers so both
controllers use a single source of truth without inheritance coupling.