- Convert const X = require('Y') to import X from 'Y.js'
- Convert const {A, B} = require('Y') to import {A, B} from 'Y.js'
- Add .js extensions to relative imports
- Keep external packages without .js (e.g., 'tinycon/tinycon')
- Convert exports.X = Y to export {X}
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(userlist): stop username input from overlapping the Log out button
Fixes#7593. In the pad's Users popup, #myusernameform had no width
set and the <input id="myusernameedit"> inside it took its natural
content width, pushing past the Log out button and making the button
overflow the popup at common widths.
Constrain #myusernameform to 75px and make the input fill its
container with box-sizing: border-box so the text field stays inside
the form and the Log out button sits visibly next to it rather than
getting covered or clipped off-screen.
Low-risk, CSS-only change. No test plan beyond visual verification
because the affected control is in the users popup UI.
* fix(chat): bottom-align titlebar controls; restore chat icon click (#7590)
Two regressions from the #7584 a11y refactor of the chat widget,
both pure-CSS fixes scoped to the chat panel.
1. Title bar — `<a>` → `<button>` for #titlecross/#titlesticky kept the
`float: right` layout, but a `<button>`'s box is only as tall as its
glyph, so the small `−` and `█` controls floated at the *top* of the
44px title bar instead of sitting on the title's baseline as the
anchors did. Switch #titlebar to a flex row with `align-items:
flex-end`, give #titlelabel `flex: 1` to push the controls to the
right edge, and use `order: 1/2` to keep the historical visual order
`[█] [−]` (which `float: right` previously produced from reverse
source order).
2. Chat-icon corner widget — `<div>` → `<button id="chaticon">` exposes
the inner `<span class="buttonicon">` to the global `.buttonicon`
rule's `display: flex; position: relative; align-items/justify-content:
center;`. The existing override only reset `display`, leaving the
span as a positioned flex item that, in some layouts, sat over the
button's hit surface and swallowed clicks. Reset the remaining flex
properties and add `pointer-events: none` so clicks always reach the
`<button>`'s own click handler — preferred over weakening the global
.buttonicon rule, which the toolbar relies on for icon centring.
Visual-only / behaviour-fix, no markup or JS changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(settings): grey disabled chat option labels (#7592)
When "Disable chat" is ticked in the Settings dialog, refreshMyViewControls()
already sets `disabled` on `#options-stickychat` and `#options-chatandusers`,
but the browser only greys the checkbox itself — the adjacent `<label>`
keeps its normal colour, so the row still looks interactive even though
clicks are no-ops.
Add a popup-scoped rule that follows the existing convention used for
disabled `.nice-select` controls (`color: #999; cursor: not-allowed`) so
any disabled checkbox or radio in a settings popup matches its label to
the disabled state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* revert(userlist): drop username input width cap (#7593 review)
The width:75px on #myusernameform and width:100%/box-sizing on
#myusernameedit from a55436ca0 were guarding against an overlap with
a "Log out" button — but no Log out button exists in vanilla
etherpad-lite (the original report came from a setup with a plugin
that adds one). Without that button visible, the cap just makes the
default username field unnecessarily narrow.
Restore #myusernameform to just `margin-left: 10px` and drop the
forced width on the input. If the overlap reappears in a real plugin
setup it should be re-fixed there (or with a more targeted rule that
only kicks in when a logout button is actually present).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat): keep titlesticky at top of title bar (#7590 review)
The previous pass bottom-aligned both corner controls via
align-items: flex-end on #titlebar. That correctly placed the close
button (#titlecross) on the title's baseline, but it also dragged the
much smaller "stick to screen" button (#titlesticky) down to the same
baseline — visibly far below where it sat in the original layout.
Switch to per-control align-self so each lands where it should:
- #titlesticky → align-self: flex-start (top, where it always was)
- #titlecross → align-self: flex-end (bottom, on the title's baseline)
- #titlelabel → align-self: center (don't stretch the heading)
Drop align-items from #titlebar so the defaults don't override these.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* revert(chat): restore original #titlebar layout (#7590 review)
Both attempted CSS layouts for the title bar (full flex with
align-items: flex-end, then per-control align-self) ended up looking
worse than the original in review. Drop all the #titlebar / #titlelabel
/ #titlecross / #titlesticky changes from 905294d5b and f37da9a62 and
restore the pre-existing float-based layout. The chat panel ships with
its original visuals; we'll revisit #7590 separately if needed.
Keeps the chat-icon click fix from 905294d5b (#chaticon .buttonicon
flex/pointer-events reset) and the focus-visible additions for the
title-bar buttons.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat): clear inline display:none in chat.show()
When the user disables chat in settings, applyShowChat(false) calls
\`$('#chatbox').hide()\` which sets the chatbox's inline display to
\`none\`. Re-enabling chat doesn't undo that — it only re-shows the
icon. Then clicking the icon runs chat.show(), which adds the
\`.visible\` class but only flips visibility, not display, so the
chatbox stays hidden by the lingering inline style and the chat
appears not to open.
Clear the inline display in chat.show() before adding the .visible
class so the box becomes visible regardless of how it got hidden.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(colibris): align username gap; grey unchecked-disabled toggles
users.css: change #myusernameform margin-left from 35px to 10px to
match the base popup_users.css. The 35px value was chosen for the
sticky chatAndUsers layout, but for the standalone Users popup it
opens an unnecessarily wide gap between the colour swatch and the
username field. (#7593 review)
form.css: drop the \`:checked\` qualifier from the disabled toggle
visual rule so unchecked-but-disabled toggles also dim. Without this,
"Chat always on screen" / "Show Chat and Users" stayed fully bright
when "Disable chat" was ticked even though the underlying inputs were
disabled. Fixes#7592 in the colibris skin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat): simple flex titlebar — CHAT _ []
Single flex row, vertically centred via align-items: center. Title
takes the remaining width with flex: 1; the two corner controls fall
in at the right edge in source order (titlecross then titlesticky),
giving the intended visual: minus on the left, sticky on the right.
Drops `float: right` from the controls, `display: inline` from the
heading, and the prior `padding-top: 2px` hack on titlesticky (flex
alignment handles the vertical position now).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat): titlebar uses underscore for minimize; symmetric padding
- Replace \`−\` with \`_\` in #titlecross. The minus glyph sits at
the centre of its em-box and read as a hyphen mid-row when the row
was vertically centred; \`_\` sits at the bottom of its em-box and
reads as a proper minimize indicator.
- Even out #titlebar horizontal padding to 9px and drop the asymmetric
\`margin-left: 4px\` on #titlelabel so CHAT on the left and the
sticky button on the right are the same distance from the bar's
edges.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat): lift #titlecross underscore 5px
The \`_\` glyph renders at the bottom of its em-box, so even with the
title bar's flex \`align-items: center\` it sits noticeably below the
CHAT baseline. Lift it with \`transform: translateY(-5px)\` (doesn't
affect flex layout calculations) so the underscore reads at roughly
the same vertical line as the title.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(chat): cover #7590 / #7592 / #7593 fixes
Adds Playwright frontend specs for the changes in this PR:
chat.spec.ts
- chat icon click reveals chatbox after disable→enable cycle
(regression: chat.show() must clear inline display:none)
- title bar lays out as a centred flex row with underscore minimize
(covers display, align-items, label flex:1, no float, translateY
lift, and visual padding symmetry via rendered geometry)
- chat icon click reliably opens the chat box (#chaticon .buttonicon
pointer/flex reset)
pad_settings.spec.ts
- disabling chat disables and visually greys the dependent chat
toggles (#7592 — checks input :disabled state and label opacity)
change_user_name.spec.ts
- #myusernameform has 10px left margin and is not width-capped
(#7593 review — colibris margin alignment, no input width cap)
Padding symmetry asserted via rendered rect deltas rather than the
CSS literal, since colibris ships its own #titlebar padding override.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(editor): undo/redo scrolls the viewport to follow the caret
Before: on a large pad, pressing Ctrl+Z (or Ctrl+Y, or the toolbar undo
button) updated the caret in the rep model and the DOM, but the viewport
did not follow when the caret landed below the visible area. The user
was left looking at the same scroll position while their change had
been undone somewhere they couldn't see.
Root cause: scroll.ts's `caretIsBelowOfViewport` branch ran
`outer.scrollTo(0, outer[0].innerHeight)` — a fixed offset equal to the
inner iframe's height, NOT the caret position. That was a special-case
added in PR #4639 to keep the caret visible when the user pressed Enter
at the very end of the pad. It worked for that one scenario because the
newly-appended `<div>` happened to be at the bottom of the pad too; for
any other way of putting the caret below the viewport (undo, redo,
programmatic selection change, deletion that collapsed a long block) it
scrolled to an arbitrary spot.
Fix: mirror the `caretIsAboveOfViewport` branch. After the deferred
render settles, recompute the caret's position relative to the viewport
and scroll by exactly the delta needed to bring the caret back in — plus
the configured margin. The Enter-at-last-line case still works because
the caret genuinely is near the bottom of the pad and the delta resolves
to "scroll down by a screen".
Closes#7007
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(7007): use real typing so undo has changesets to replay
The first iteration of the Playwright spec built the pad by writing
directly to #innerdocbody.innerHTML. That bypasses Etherpad's text
layer, so the undo module had no changeset to revert — Ctrl+Z became a
no-op and the scroll assertion saw no movement (CI failure output:
`Expected: < 2302, Received: 2302`).
Replace with real keyboard typing of 45 lines via the existing
writeToPad-style pattern, then make the edit + scroll + Ctrl+Z under
that real content. Slower (~5s per test) but faithful to how undo
interacts with the pad.
Also drop the `test.beforeEach(clearCookies)` scaffolding — it wasn't
doing anything useful here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(7007): scroll caret into view directly in doUndoRedo
Revert the scroll.ts rewrite from the previous commits and move the
fix to the right abstraction layer: the undo/redo entry point itself.
`scrollNodeVerticallyIntoView`'s caret-below-viewport branch has a
well-documented special case (PR #4639) that scrolls to the inner
iframe's innerHeight so Enter-on-last-line stays smooth. Changing
that function for the undo case risked regressing the Enter case or
racing with the existing scrollY bookkeeping. The CI run showed the
rewrite wasn't actually producing viewport movement.
Do the simpler thing instead: in `doUndoRedo`, after the selection is
updated, call `Element.scrollIntoView({block: "center"})` on the
caret's line node. That's browser-native, works inside the
ace_inner / ace_outer iframe chain, doesn't need setTimeout, and matches
what gedit/libreoffice do.
Closes#7007
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: page down/up now scrolls by viewport height, not line count
The previous implementation counted logical lines in the viewport,
which failed when long wrapped lines consumed the entire viewport.
Now scrolls by actual pixel height for correct behavior.
Fixes#4562
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use outerDoc instead of outerWin.document for viewport height in PageDown/Up
outerWin is an HTMLIFrameElement (returned by getElementsByName), not a
Window object, so it has no .document property. The existing getInnerHeight()
helper already uses outerDoc.documentElement.clientHeight correctly; align
the PageDown/PageUp handler with that pattern.
Adds a Playwright regression test that verifies PageDown scrolls the
viewport when the pad contains long wrapping lines.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: rewrite page down/up to use pixel-based line counting
The previous approach tried to scroll the outerWin iframe element
directly which didn't work. Reverted to the original cursor-movement
approach but calculates lines-to-skip using viewport pixel height
divided by actual rendered line heights. This correctly handles long
wrapped lines that consume multiple visual rows.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: restore getInnerHeight + inclusive range fixes lost in rebase
Recover the PageDown/Up fixes that got dropped when this branch was
rebased onto develop:
- Use getInnerHeight() instead of outerDoc.documentElement.clientHeight
so hidden-iframe and Opera edge cases are handled the same as the rest
of the editor.
- scroll.getVisibleLineRange() returns an inclusive end index, so count
(end - start + 1) logical lines to match the pixel-sum loop bounds.
- Replace the flaky 'PageDown scrolls viewport' test with the robust
#4562 regression that builds long wrapped lines via direct DOM and
asserts the caret advances on successive PageDown presses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After picking a value from a toolbar <select> (ep_headings style
picker is the canonical case), keyboard focus was left on the
nice-select wrapper rather than returned to the pad editor. Users
had to click back into the pad before typing resumed.
The ToolbarItem.bind() class already calls padeditor.ace.focus() at
the end of triggerCommand, but that only runs for selects wired via
data-key on the wrapping <li>. Plugin-provided selects (e.g.
ep_headings2's #heading-selection inside <li id="headings">, no
data-key) don't go through that path — they bind their own change
handler and never return focus.
Fix: add a delegated change handler on `#editbar select` that calls
padeditor.ace.focus() after any toolbar select change. Deferred via
setTimeout(0) so plugin change handlers (bound on the same event)
complete their ace.callWithAce work before focus moves. Redundant but
harmless for data-key-wired selects that are already refocused by
triggerCommand.
Added a Playwright regression test that simulates the nice-select
option-click (val + change, which is what the wrapper dispatches
internally) and verifies typing after the change lands in the pad.
Skips when ep_headings2 isn't installed.
Closes#7589.
* fix(a11y): negotiate lang/dir per request and set on <html>
Server-renders the html element with `lang` and `dir` matching the
client's Accept-Language header (negotiated against availableLangs from
i18n hooks). Falls back to `en`/`ltr` if no match.
This gives screen readers a correct document language during the brief
window before client-side html10n refines it (l10n.ts already sets both
attributes after locale data loads).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): dialog semantics on popups; fix aria-role typo on userlist
Adds role=dialog, aria-modal=true, and either aria-labelledby (when an
h1 is present) or aria-label (for popups without an h1) to:
- #settings, #import_export, #embed, #skin-variants (labelledby)
- #connectivity, #users, #mycolorpicker (aria-label)
Fixes the invalid aria-role="document" attribute on #otherusers; it's
now role=region with aria-live=polite so screen readers announce
collaborator joins/leaves.
Container aria-label values are English-only for now — Etherpad's
html10n implementation only supports localizing specific attributes
(title, alt, placeholder, etc), not aria-label on container nodes.
Localization can follow once html10n grows that affordance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): focus management and Escape-to-close for popups
Three additions to toggleDropDown / _bodyKeyEvent:
- Remember the trigger element (document.activeElement) when opening
a popup, so we can restore focus when it closes.
- On open, focus the first focusable element inside the popup so
keyboard users land inside the dialog instead of staying on the
trigger button.
- Escape pressed while focus is inside a popup closes it, then the
restore-focus path runs and the trigger button is refocused.
Replaces the previous behavior where Escape from inside a popup did
nothing; users had to click outside to dismiss.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): make chaticon and chat header controls real buttons
- #chaticon: <div onclick> → <button type=button> with aria-label
- #titlecross / #titlesticky: <a onClick> → <button type=button>
with aria-label (Close chat / Pin chat to screen)
- Decorative chat-bubble glyph gets aria-hidden=true so it isn't
read alongside the button label
- #chatcounter labelled "Unread messages"
- Inline onclick attributes moved to chat.init() handlers
- CSS reset on the new buttons (transparent bg, no border, inherit
font/color) so they match the prior visual design
- :focus-visible outlines for keyboard users
Existing test selectors (#chaticon, #titlecross, #titlesticky) are
unchanged and continue to work — they never relied on element type.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): accessible names for icon-only toolbar/export controls
- Export links (#exportetherpada, #exporthtmla, #exportplaina,
#exportworda, #exportpdfa, #exportopena): added aria-label so the
link is announced as e.g. "Export as PDF". The inner icon span
gets aria-hidden=true so screen readers don't read both the icon
text and the link label.
- Show-more toolbar toggle (.show-more-icon-btn): converted from
<span> to <button type=button> with aria-label and aria-expanded.
The click handler now toggles aria-expanded alongside the
full-icons class so assistive tech reflects the open/closed state.
- Theme switcher knob: aria-label changed from "theme-switcher-knob"
(a class-style identifier, not human text) to "Toggle theme".
Aria-label values are English-only for now. Etherpad's html10n
implementation only localizes a fixed attribute list (title, alt,
placeholder, value, innerHTML, textContent); aria-label is not
included, so a clean l10n path requires a follow-up to either
extend html10n or set aria-label client-side after locale loads.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(a11y): cover dialog semantics, html lang, icon button labels
New Playwright spec verifies the a11y guarantees added by this branch:
- <html> has a non-empty lang attribute
- settings/import_export/embed/users popups expose role=dialog,
aria-modal=true, and either aria-labelledby (when an h1 exists)
or aria-label (when none does)
- Escape from inside the settings popup closes it AND restores
focus to the trigger button
- Export links each carry a descriptive aria-label
- #chaticon is a real <button> with aria-label
- #titlecross / #titlesticky are real <button>s with aria-label
- #otherusers uses role=region + aria-live=polite + aria-label
(and the previous aria-role typo is gone)
- .show-more-icon-btn is a <button> with aria-label and
aria-expanded
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): address Qodo review feedback from PR #7584
1. Users Escape close broken - toggleDropDown('none') intentionally
skips the users module so switching between other popups doesn't
hide the user list. That meant Escape couldn't dismiss the Users
popup either. The Escape branch now checks for #users as the
focused popup and closes it explicitly (respecting stickyUsers)
before falling through to the normal close-all path.
2. Embed focus overridden - the rAF auto-focus in toggleDropDown
grabbed the first focusable descendant, which stole focus from
command handlers that target a specific control (notably the Embed
command's #linkinput). rAF now bails out if focus is already
inside the newly-opened popup.
3. Button click blurs :focus before toggleDropDown captures trigger -
discovered while investigating the Firefox Playwright failure for
"settings popup Escape restores focus". Button.bind() calls
$(':focus').trigger('blur') before invoking the callback, so by
the time toggleDropDown() captured document.activeElement as the
restore target it was already <body>. The click handler now
stashes padeditbar._lastTrigger to the clicked <button> before
blur runs; toggleDropDown only falls back to activeElement when
the pre-stash didn't happen (keyboard shortcut path).
4. html10n overwrites aria-label - html10n unconditionally set
aria-label to the translated string, clobbering explicit aria-label
on elements that also carry data-l10n-id. setAttribute now only
fires when the element has no aria-label; explicit author labels
win, unlabelled translated elements still get a name.
5. Button visual reset - the show-more-icon-btn and #chaticon
conversions inherited UA default button border/background/padding,
shifting icon glyphs visibly off-centre. Added appearance /
background / border / padding resets.
6. Export links test assumes soffice is installed - #exportworda,
#exportpdfa, #exportopena are removed client-side by pad_impexp.ts
when clientVars.exportAvailable === 'no'. The test now skips links
absent at runtime.
Verified locally: all 10 a11y_dialogs specs pass on both Chromium and
Firefox; backend suite remains 799/799 passing; ts-check clean.
* fix(a11y): close popups with no focusable content; unbreak chat-icon layout
Round 2 of #7584 review follow-ups.
1. Users popup Escape still didn't close the dialog (user-confirmed).
Root cause: _bodyKeyEvent is bound to the OUTER document's body.
When #users opens, the command handler tries to focus
#myusernameedit but that input is `disabled`, so focus stays in the
ace editor iframe. Keydown from inside the iframe does not bubble
to the outer document, so Esc never reaches _bodyKeyEvent.
Fix: in the open-popup rAF, if no command handler placed focus
inside the dialog, focus the popup div itself (with tabindex=-1).
That keeps subsequent keydown events on the outer document so
Esc can dismiss the popup. Also broadened the Esc branch to fire
whenever any popup is `.popup-show`, regardless of where :focus
lives — some popups legitimately have no focusable content at
open.
Added a regression test that opens #users and asserts Esc closes
it. Passes on both Chromium and Firefox.
2. Chat icon (#chaticon) visual still wrong after the first CSS fix.
- My previous `border: 0` reset was overriding the intended
`border: 1px solid #ccc; border-bottom: none` from the earlier
rule. Removed `border: 0`; the earlier explicit border suffices
to suppress UA defaults.
- The `<span class="buttonicon">` inside `#chaticon` was picking
up the global `.buttonicon { display: flex; }` rule meant for
toolbar button instances, which broke the inline layout of the
label + glyph + counter row. Added a scoped
`#chaticon .buttonicon { display: inline; }` override.
All 11 a11y_dialogs specs pass on Chromium and Firefox. Backend
suite and ts-check remain clean.
* fix(a11y): only stash _lastTrigger for dropdown-opening buttons
Round 3 follow-up. The previous Button.bind() change stashed every
clicked toolbar button as padeditbar._lastTrigger before blurring :focus.
That was necessary for popup-opening buttons (settings, import_export,
etc.) so Escape could return focus to them — but it also fired for
non-popup toolbar buttons (list toggles, bold/italic, indent/outdent,
clearauthorship). For those, the stash held a stale reference that
interfered with subsequent editor interactions and regressed Playwright
tests: ordered_list, unordered_list, undo_clear_authorship.
Fix: only stash when the clicked command is a registered dropdown
(settings, import_export, embed, showusers, savedrevision,
connectivity). Other commands return focus to the ace editor as before
and leave _lastTrigger alone.
Verified locally on Chromium:
- ordered_list.spec.ts: 6/6 pass (was 4/6)
- unordered_list.spec.ts: 6/6 pass (was 4/6)
- undo_clear_authorship.spec.ts: 2/2 pass (was 0/2)
- a11y_dialogs.spec.ts: 11/11 pass (unchanged)
* fix(a11y): address Qodo review round 4 for PR #7584#1 Stale aria-label after relocalize
html10n.translateNode() refused to overwrite any existing aria-label,
which also skipped updates on language change (pad.applyLanguage()
re-runs localize). Use a `data-l10n-aria-label="true"` marker: set
aria-label + marker when html10n populates it, overwrite only if the
marker is present. Explicit template-supplied aria-labels stay as-is;
html10n-generated ones refresh on relocalize.
#2 Escape won't close colorpicker
_bodyKeyEvent caught Escape on any `.popup.popup-show` but only
closed dropdown popups via toggleDropDown('none'). Popups opened
outside the editbar framework (#mycolorpicker, toggled directly by
pad_userlist.ts) stayed open while preventDefault() swallowed the
key. Now the Escape branch manually closes any popup that
toggleDropDown('none') cannot reach (non-dropdown ids, plus #users
unless pinned) and leaves registered dropdowns for toggleDropDown to
close so its focus-restore sees the transition.
#3 Stale focus restoration
toggleDropDown('none') restored focus to _lastTrigger even when no
popup was open on entry, which meant background callers
(connectivity setup, periodic state handling) could yank focus out
of the editor to a stale toolbar button. Gated the restore on
`wasAnyOpen === true` so it only fires when there was a popup to
close.
#11 English aria-label overrides i18n (export links, chat icon)
Removed the hard-coded English aria-label from export anchors and
removed aria-hidden from their inner localized spans. Screen readers
now get the localized child text as the accessible name (Etherpad,
HTML, PDF, etc.), matching the visible UI language.
Removed the English aria-label from #chaticon and #titlesticky as
well — both have data-l10n-id, so html10n populates a localized
aria-label via the marker mechanism in #1. #titlecross keeps its
static aria-label because it has no data-l10n-id yet.
#4 4-space indent in a11y spec
Two tests had continuation lines at 4-space indent violating the
repo's 2-space rule. Folded the signatures onto one line.
Updated a11y_dialogs.spec.ts to assert accessible-name presence rather
than hard-coded English for elements whose names now come from the
localized text. Still asserts static English for #titlecross (not
localized yet).
Verified locally (dev server restarted for each round):
- a11y_dialogs.spec.ts: 11/11 on Chromium, 11/11 on Firefox
- ordered_list + unordered_list + undo_clear_authorship: 13/13 on Chromium
- Full backend suite: 799 passing, 0 failing
- tsc --noEmit clean in our code
#9 Popup behavior documentation: deferred to a follow-up doc PR so
this PR stays focused on the a11y code changes. The new keyboard
behavior (Escape-to-close, focus-restore-to-trigger) is small enough
to summarize in a short doc/ addition.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(editor): preserve U+00A0 non-breaking space (#3037)
Non-breaking spaces were silently normalized to regular spaces at every
ingestion point, so typed/pasted/imported nbsps never reached the
changeset and users could not glue words against line-wrap in French or
other languages that require nbsp typography.
Removed the four strip sites that replaced U+00A0 with U+0020:
- src/node/db/Pad.ts cleanText
- src/static/js/contentcollector.ts textify
- src/static/js/ace2_inner.ts textify
- src/static/js/ace2_inner.ts importText raw-text guard
Updated both processSpaces functions (domline and ExportHtml) to tokenize
U+00A0 as a separate unit, emit it verbatim as , and treat it as
content (not whitespace) for the run-collapse bookkeeping so adjacent
regular-space runs aren't miscounted.
Added backend round-trip tests for spliceText and setText, and extended
the cleanText case table. Updated the existing contentcollector and
importexport specs whose expectations encoded the previous buggy
behavior; they now assert genuine nbsp preservation.
Verified manually in Firefox: clipboard U+00A0 → paste → pad → getText
returns c2 a0; getHTML emits `100 km`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(contentcollector): collapse display-artifact nbsp runs on DOM read-back
processSpaces is a lossy one-way display transform: leading/trailing
spaces and all-but-the-last of a run get rendered as so HTML
doesn't collapse them. When incorporateUserChanges reads text back from
the DOM, those display-artifact nbsps were being stored in the changeset
model instead of being normalized back to plain spaces.
This broke handleReturnIndentation, whose /^ *(?:)/ regex only matches
ASCII spaces: auto-indent after `foo:\n` produced 4 spaces instead of
the expected prev-indent (2) + THE_TAB (4) = 6, because the previous
line's model had nbsps where it used to have spaces.
Fix: in contentcollector.textify, collapse any [ ]+ run back to
plain spaces UNLESS the run is pure U+00A0 AND strictly interior to
word chars. That preserves user-intended typographic nbsps like
"100 km" while undoing the one-way display transform.
Updated 7 contentcollector tests and 7 importexport tests whose
assertions needed to reflect the new rule (boundary/mixed runs collapse;
pure-interior nbsp runs preserve).
Fixes the Playwright regression in indentation.spec.ts:117 that the
previous commit introduced.
* fix(contentcollector): canonicalize nbsp runs at line assembly, not per text node
Addresses Qodo code review feedback on PR #7585.
## Bug fix — nbsp lost at DOM text-node boundary
The previous approach ran the "collapse display-artifact nbsp" rule inside
textify(), which is called per individual DOM TEXT_NODE. A user-intended
nbsp sitting at a text-node boundary (e.g., <span>100</span><span> km
</span>) was incorrectly seen as non-interior (before === '' for the second
text node) and normalized back to a regular space.
Fix: move the canonicalization out of textify() and run it on each
fully assembled line string inside cc.finish(). The rule remains:
[ ]+ run -> plain spaces
UNLESS pure U+00A0 AND strictly interior to non-ws chars
It is length-preserving, so attribute offsets and line lengths are
unaffected.
Added a regression test (contentcollector.spec.ts) for the cross-span
case.
## Docs concern
Reverted the type-only addition of spliceText to PadType. spliceText
is an existing Pad runtime method; the backend test now uses a cast
(`(pad as any).spliceText`) so the PR does not expand the declared
public type surface, avoiding a separate documentation requirement.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: add timeslider line numbers
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* perf: coalesce timeslider line-number updates
Addresses Qodo review: updateLineNumbers() was called synchronously
from applyChangeset() on every changeset, forcing full-document layout
reads/writes during timeslider scrubbing/playback. scheduleLineNumberUpdate()
also queued a fresh double-rAF pair for every resize tick. Add a pending
flag so only one rAF pair is in flight, and route applyChangeset() through
the scheduler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: warn when a pending edit is not accepted
Show a gritter warning only when the pad disconnects while a local commit is still awaiting acceptance, leaving normal editing UI unchanged.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* test: cover unaccepted-commit warning path
Addresses Qodo review: adds regression coverage for the two contract
changes this PR introduces — acceptCommit() must clear the pending
marker so hasUnacceptedCommit() returns false after a server ACK, and
the disconnect handler must surface the unsaved-edit gritter when a
commit is still pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: allow undo of clear authorship colors without disconnect (#2802)
When a user clears authorship colors and then undoes, the undo changeset
re-applies author attributes for all authors who contributed text. The
server was rejecting this because it treated any changeset containing
another author's ID as impersonation, disconnecting the user.
The fix distinguishes between:
- '+' ops (new text): still reject if attributed to another author
- '=' ops (attribute changes on existing text): allow restoring other
authors' attributes, which is needed for undo of clear authorship
Also removes the client-side workaround in undomodule.ts that prevented
clear authorship from being undone at all, and adds backend + frontend
tests covering the multi-author undo scenario.
Fixes: https://github.com/ether/etherpad-lite/issues/2802
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use robust Playwright assertions in authorship undo tests
- Use toHaveAttribute with regex instead of raw getAttribute + toContain
- Check div/span attributes within pad body instead of broad selectors
- Use Playwright auto-retry (expect with timeout) instead of toHaveCount(0)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: handle confirm dialog and sync timing in Playwright tests
- Add page.on('dialog') handler to accept the confirm dialog triggered
by clearAuthorship when no text is selected (clears whole pad)
- Use auto-retrying toHaveAttribute assertions instead of raw getAttribute
- Increase cross-user sync timeouts to 15s for CI reliability
- Add retries: 2 to multi-user test for CI flakiness
- Scope assertions to pad body spans instead of broad selectors
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use persistent socket listeners to avoid missing messages in CI
Replace sequential waitForSocketEvent loops with single persistent
listeners that filter messages inline. This prevents race conditions
where messages arrive between off/on listener cycles, causing timeouts
on slower CI runners.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: reject - ops with foreign author to prevent pool injection
The '-' op attribs are discarded from the document but still get added
to the pad's attribute pool by moveOpsToNewPool. Without this check, an
attacker could inject a fabricated author ID into the pool via a '-' op,
then use a '=' op to attribute text to that fabricated author (bypassing
the pool existence check).
Now all non-'=' ops (+, -) with foreign author IDs are rejected.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: use not.toHaveClass for cleared authorship spans
Addresses Qodo review: linestylefilter skips attribs with empty values,
so a span with author='' has no class attribute at all. The previous
negative-lookahead regex on the class attribute failed against a null
attribute and was flaky in CI. Switch to not.toHaveClass(/author-/),
which also passes when the attribute is missing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a core timeslider playback speed setting with an original-speed default, a realtime mode that uses revision timestamps, and frontend coverage for the new behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat!: replace Abiword with LibreOffice and add DOCX export (#4805)
The Abiword converter is dropped. Abiword's DOCX export is weak and the
project is niche on modern platforms; LibreOffice (soffice) is the
common deployment path and now serves as the sole converter backend.
DOCX is added as an export format and becomes the new target for the
"Microsoft Word" UI button. The /export/doc URL still works for legacy
API consumers.
BREAKING CHANGE: The 'abiword' setting, the INSTALL_ABIWORD Dockerfile
build arg, the abiwordAvailable clientVar, and the
#importmessageabiword UI element (with locale key
pad.importExport.abiword.innerHTML) are removed. Deployments relying on
Abiword must configure 'soffice' instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: add docxExport feature flag and abiword deprecation WARN
- Add `docxExport: true` setting to opt out of DOCX (use legacy DOC)
- Pass `docxExport` to client via clientVars
- Use `docxExport` flag in pad_impexp.ts for Word button format
- Emit a specific WARN when deprecated `abiword` config is detected
- Update settings.json.template and settings.json.docker with docxExport
- Add docxExport to ClientVarPayload type in SocketIOMessage.ts
Agent-Logs-Url: https://github.com/ether/etherpad/sessions/9afc5291-73b2-4b66-b028-feed39e7056f
Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com>
* refactor: extract wordFormat variable and improve docxExport comment
Agent-Logs-Url: https://github.com/ether/etherpad/sessions/9afc5291-73b2-4b66-b028-feed39e7056f
Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com>
* fix: restore import-limitation message when no converter is configured
The abiword removal dropped both the #importmessageabiword DOM element
and its locale key, but Copilot's refactor still expected the show()
call to surface a message when exportAvailable === 'no'. Result: users
with no soffice binary got silent failure instead of an explanation.
Add #importmessagenoconverter back with updated, LibreOffice-focused
copy (new locale key pad.importExport.noConverter.innerHTML) and flip
the hidden prop when the client knows no converter is available.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* i18n: inline English fallback for noConverter import message
The original abiword message existed in ~70 locale files and was
removed from all of them by this PR. The replacement key was only
added to en.json, so non-English users had an empty div until
translators localize. Follow the project's usual pad.html pattern
(e.g. line 146's "Font type:") and include the English text inside
the div as the fallback content; html10n replaces it when a
translation is available.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Revert "i18n: inline English fallback for noConverter import message"
This reverts commit f336f24d. Follow the project convention: add the
new locale key to en.json only and let translations catch up via the
translation system, rather than putting inline fallback in the template.
* i18n: leave non-English locale files untouched
The PR had removed pad.importExport.abiword.innerHTML from ~82 locale
files alongside its removal from en.json. The replacement message uses
a new key (pad.importExport.noConverter.innerHTML) in en.json only, so
churning every localisation file for a key that is no longer referenced
produces useless translation diffs. Restore every non-en locale file to
its pre-PR state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com>
* fix: flush pending changesets immediately after reconnect
After reconnecting, setUpSocket() did not call handleUserChanges(),
so any edits made while disconnected were not sent to the server until
the user made another change. This caused divergent pad state between
users.
Now calls handleUserChanges() after reconnect to immediately transmit
any pending local changesets.
Fixes#5108
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: defer handleUserChanges on connect to avoid editor init race
Calling handleUserChanges() synchronously in setUpSocket() caused
"Cannot read properties of null (reading 'changeset')" because the
editor isn't fully initialized on first connect. Deferred with
setTimeout(500ms) to allow initialization to complete.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: add test for pending changeset flush after reconnect
Verifies that edits made while disconnected are transmitted to the
server immediately upon reconnection.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: flush pending changesets on actual reconnect, not just initial connect
setUpSocket() only runs during initialization. Move handleUserChanges()
to the reconnect code path so pending edits are flushed when the
connection is re-established.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: flush pending changes when isPendingRevision clears after reconnect
The existing fix in setChannelState('CONNECTED') calls handleUserChanges(),
but at that point isPendingRevision is still true so changes are blocked.
The real trigger must be in setIsPendingRevision(): when it transitions
from true to false (after all CLIENT_RECONNECT messages are processed),
call handleUserChanges() to flush any locally-queued edits.
Also adds a targeted regression test that simulates the exact reconnect
state transitions and verifies pending edits reach the server.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: rewrite reconnect flush test for reliability
Replaced the fragile offline/online simulation with a direct test that
uses separate browser contexts. Simplified to a single test that
exercises the exact setIsPendingRevision(false) -> handleUserChanges()
codepath and verifies the flushed text is visible from another client.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: remove Playwright reconnect test (not feasible)
The reconnect test requires access to pad.collabClient internals which
are not exposed on window in the browser context. Playwright cannot
call setStateIdle/setIsPendingRevision/setChannelState. The backend
tests adequately cover the code fix.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: don't flush in setChannelState to avoid editor init race
Calling handleUserChanges() in setChannelState('CONNECTED') fires
synchronously on first connect before the editor is fully initialized,
breaking chat/user_name tests. The setIsPendingRevision(false) trigger
is sufficient for the reconnect path, and the existing
setTimeout(handleUserChanges, 500) in setUpSocket() already handles
the initial connect.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove setTimeout flush in setUpSocket — rely on setIsPendingRevision trigger
The setTimeout(handleUserChanges, 500) in setUpSocket was a timing hack
that:
- Only fires on initial connect (setUpSocket is called once at the end
of getCollabClient; reconnects go through pad.ts:248 which calls
setChannelState('CONNECTED') directly, bypassing setUpSocket).
- Doesn't actually fix issue #5108 (the reconnect-flush bug). That's
fixed deterministically by the wasPending && !value trigger in
setIsPendingRevision, which fires whenever the server's CLIENT_RECONNECT
message lands (both for noChanges and after replaying revisions).
- Introduced a 500ms race window on initial pad load.
The reconnect path now relies entirely on the deterministic event-based
trigger (setIsPendingRevision), with no timing assumptions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When pressing Enter in a line with attributes like heading or align,
the attributes were lost on the new line. Lists had special-case code
in doReturnKey() but all other line attributes were ignored.
This adds a new client hook aceRegisterLineAttributes that plugins can
use to declare which attributes should be preserved when a line is
split by Enter:
- Enter at middle/end of line: attribute is copied to the new line
- Enter at start of line (col 0): attribute moves down with the text,
the now-empty line above gets the attribute removed
Plugins register by returning attribute names from the hook:
exports.aceRegisterLineAttributes = () => ['heading'];
Fixesether/ep_headings2#7
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use correct path for connection diagnostics POST
The relative path '../ep/pad/connection-diagnostic-info' resolved
incorrectly in subdirectory setups. Use absolute path from the
application root.
Fixes#4191
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: verify connection diagnostics endpoint is reachable
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: numbered list wrapped lines now indent correctly
Changed text-indent to padding-left for ordered list indentation.
text-indent only affects the first line, so wrapped text didn't
align with the numbered content above it.
Fixes#2581
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: verify numbered list uses padding-left instead of text-indent
Regression test for #2581. Verifies that ordered list items use
padding-left (which indents all lines including wrapped ones) rather
than text-indent (which only indents the first line).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: RTL URL parameter rtl=false now correctly disables RTL mode
The rtl parameter callback only handled rtl=true (checkVal was 'true'),
so rtl=false was ignored and the layout stayed in RTL from the cookie.
Now accepts any value and sets rtlIsTrue = (val === 'true'). Also
always applies the RTL setting instead of only when true, so switching
from rtl=true to rtl=false takes effect.
Fixes#5559
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: only override RTL when explicitly set via URL/server config
The unconditional changeViewOption('rtlIsTrue', false) overwrote
cookie-persisted RTL preferences and language-direction defaults.
Track explicit setting with rtlIsExplicit flag so we only override
when the user or server actually specified an rtl value.
Adds regression tests for rtl=true, rtl=false, and cookie persistence.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: move RTL override into postAceInit to fix race condition
The RTL changeViewOption call was racing with padeditor.init() — the
async setViewOptions(initialViewOptions) at the end of init overwrote
the URL-param-based RTL setting. Moving it into postAceInit ensures
padeditor is fully initialized. Also switched tests to use Playwright
auto-retrying assertions for robustness.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve Playwright test failures for RTL URL parameter
Three issues fixed:
- setCheckbox used .attr('checked') instead of .prop('checked'), so the
JS checked property was never set and Playwright saw unchecked state
- html10n localized event overwrote RTL setting from URL params and
cookie preferences; now skips override when either is active
- Server default padOptions.rtl:false was treated as explicit, overwriting
cookie-persisted RTL; added fromUrl flag to distinguish URL from server
All 94 Playwright tests and 740 backend tests pass locally.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: correct readFileSync calls in LinkInstaller to fix plugin installation
pathToFileURL() was incorrectly wrapping paths passed to readFileSync(),
causing ENOENT errors that were silently caught. Using plain paths with
'utf-8' encoding fixes plugin dependency resolution.
Fixes#6811
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: add backend tests for LinkInstaller dependency resolution
Covers the readFileSync fix from the plugin installation bug where
pathToFileURL incorrectly wrapped file paths.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: only track dependency in map after successful setup
Previously dependenciesMap.set() ran after the catch block, marking
dependencies as tracked even when linking or package.json reading
failed. This blocked later cleanup via removeSubDependency().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve line attributes on neighboring lines during drag-and-drop
On Chrome and Safari, when dragging a line in a list, the browser's
contentEditable engine merges the removed line with its neighbor,
corrupting the neighbor's line attributes (e.g., changing its list
type).
The drop handler now captures line attributes of the lines adjacent
to the dragged content before the browser processes the drop. After
incorporateUserChanges runs, it checks if those attributes were
corrupted and restores them.
Note: this bug cannot be reproduced in Playwright's headless browsers
(DnD in contentEditable iframes isn't supported), so manual testing
with Chrome/Safari is required.
Fixes#3120
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: also save/restore lines with no list type during DnD
Lines with no list attribute can get corrupted to inherit the dragged
line's list type. Now saves all adjacent lines (including those with
no list type) and properly removes corrupted attributes when restoring.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: null-safety for atKey in drop handler
Guard against atKey returning null for dynamically inserted nodes
that aren't in the rep.lines index.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: bold text retains formatting after copy-paste
When pasting bold (or italic, underline, etc.) text, the browser's
contentEditable engine normalized the pasted DOM before Etherpad's
content collector could extract the formatting. The pasted HTML
contained proper <b> tags, but the browser flattened the nested
ace-line divs and stripped the inline formatting in the process.
Now the paste handler checks clipboard HTML for formatting tags. If
found, it prevents default browser paste, parses the HTML in a
detached DOMParser document, and inserts the nodes directly into the
editor. This preserves the formatting tags for the content collector.
Fixes#5037
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* security: sanitize pasted HTML to prevent XSS via clipboard
Strip dangerous elements (script, style, iframe, object, embed, form,
link, meta) and event handler attributes (onclick, onerror, etc.) from
pasted HTML before inserting into the editor. Also removes javascript:
URLs from href attributes.
DOMParser doesn't execute scripts, but importNode copies all attributes
including event handlers that execute when inserted into the live
document.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On Firefox Linux, when typing accented characters with a dead key or
compose key, the space before the character was being deleted. This
happened because the keydown event for the dead key (keyCode 229)
fired before compositionstart, so inInternationalComposition wasn't
set yet and observeChangesAroundSelection() ran prematurely, capturing
a pre-composition DOM state.
Now treats keyCode 229 (the standard IME/composition keyCode) the same
as other half-character inputs: defers the idle timer and suppresses
normalization until the composition completes.
Fixes#5623
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Changed min-width and max-width on .popup-content to use min() with
viewport-relative units so the popup doesn't overflow on screens
narrower than 300px, keeping the close button accessible.
Fixes#7246
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Browser extensions (BitWarden, Dashlane, etc.) inject scripts that can
throw errors caught by Etherpad's global exception handler, showing a
scary error popup and sometimes blocking the editor from loading.
Two fixes:
- globalExceptionHandler (pad_utils.ts): Skip errors where the source
URL matches moz-extension://, chrome-extension://, or
safari-extension:// patterns.
- Ace2Editor.init (ace.ts): The eventFired() error callback now checks
if the error event's target src is a browser extension and ignores
it, preventing extension-injected script failures from killing
editor initialization.
Fixes#6802
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: customLocaleStrings not applied due to aggressive locale caching
The admin panel's i18next backend used fetch with cache: "force-cache",
causing the browser to serve stale locale JSON even after the server
restarted with new customLocaleStrings in settings.json. The server
already sets appropriate Cache-Control headers (max-age based on
settings.maxAge), so the client-side force-cache was redundant and
prevented custom strings from appearing.
Fixes#6390
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: URL lang param now reliably overrides server default language
getParams() was processing server options first and URL params second,
both calling html10n.localize() for the lang setting. Since localize()
is async, the two calls raced and the result was nondeterministic.
Now processes each setting once: URL param wins if present, otherwise
falls back to server option. This eliminates the race condition.
Fixes#5510
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: window._() localization function always available for plugins
The html10n gettext shortcut window._ was only set if window._ was
undefined, but underscore.js was already setting it via the esbuild
bundle. Since internal code uses underscore via require() not window._,
it's safe to always set window._ to html10n.get so plugins can use
window._() for localization in hooks like documentReady.
Fixes#6627
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: make cookie names configurable with prefix setting
Add cookie.prefix setting (default "ep_") that gets prepended to all
cookie names set by Etherpad. This prevents conflicts with other
applications on the same domain that use generic cookie names like
"sessionID" or "token".
Affected cookies: token, sessionID, language, prefs/prefsHttp,
express_sid.
The prefix is passed to the client via clientVars.cookiePrefix in the
bootstrap templates so it's available before the handshake. Server-side
cookie reads fall back to unprefixed names for backward compatibility
during migration.
Fixes#664
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: default cookie prefix to empty string for backward compatibility
Changing the default to "ep_" would invalidate all existing sessions
on upgrade since express-session only looks for the configured cookie
name. Default to "" (no prefix) so upgrades are non-breaking — users
opt-in to prefixed names by setting cookie.prefix in settings.json.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address Qodo review — cookie prefix migration and fallbacks
- l10n.ts: Read prefixed language cookie with fallback to unprefixed
- welcome.ts: Use cookiePrefix for token transfer reads
- timeslider.ts: Use prefix for sessionID in socket messages
- pad_cookie.ts: Fall back to unprefixed prefs cookie for migration
- indexBootstrap.js: Pass cookiePrefix via clientVars to welcome page
- specialpages.ts: Pass settings to indexBootstrap template
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: escape regex metacharacters in cookie prefix, document Vite hardcode
- l10n.ts: Escape special regex characters in cookiePrefix before using
it in RegExp constructor to prevent runtime errors
- padViteBootstrap.js: Add comment noting the hardcoded prefix is
dev-only and must match settings.json
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* security: validate cookie prefix to prevent header injection
Reject cookie.prefix values containing characters outside
[a-zA-Z0-9_-] to prevent HTTP header injection via crafted cookie
names (e.g., \r\n sequences). Falls back to empty prefix with an
error log.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: list bugs — indent export, renumber performance, and batching
Addresses four list-related bugs:
#4426: Indented text exports as bulleted lists. Added list-style-type:none
to indent-type <ul> elements in ExportHtml.ts so exported indented content
doesn't show bullet markers.
#3504 / #5546: List operations (indent, outdent, toggle) on large lists
are O(n²) because renumberList() runs after each individual line change.
Added _skipRenumber batching flag to setLineListType() — bulk operations
in doInsertList() and doIndentOutdent() now set all line types first,
then renumber once at the end.
#6471: Ordered list numbering in exports — the start attribute is already
read from the pad's atext during export. The client-side renumberList()
correctly sets start attributes which are persisted. Added export test
to verify numbering is preserved across bullet interruptions.
Fixes#4426, #3504, #5546
Related: #6471
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address Qodo review — exception safety, batch removal, renumber scope
- Wrap _skipRenumber in try/finally to prevent permanent disabling on error
- Move list removal (togglingOff) into the batched mods array instead of
calling setLineListType directly (fixes O(n²) for list removal)
- Use firstLine instead of mods[0][0] for renumbering since the first
mod may be an indent/removal that renumberList skips
- Rewrite indent export test to actually create indent lines via setHTML
and unconditionally assert list-style-type:none is present
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: rewrite export tests to use importHtml/exportHtml directly
The HTTP API approach (setHTML via supertest) was hanging when tests
ran standalone because the API endpoint waited for something in the
request pipeline. Using importHtml.setPadHTML and exportHtml.getPadHTML
directly is faster and more reliable.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update importexport test to expect list-style-type on indent ul
The indent export fix adds style="list-style-type: none;" to indent
<ul> elements, which broke the golden test string comparison.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: accessibility — keyboard trap, screen reader support, aria-live
Three accessibility fixes:
#6581 (WCAG 2.1.2 keyboard trap): Escape key now moves focus from the
editor to the first toolbar button, giving keyboard-only users an
escape route. Added a screen-reader-only hint about Escape and Alt+F9.
#7255 (screen reader access): Added role="textbox", aria-multiline="true",
and aria-label="Pad content" to the contenteditable body so screen
readers can identify and interact with the editor content. Fixed
non-standard aria-role="document" to role="document" in pad.html.
#5695 (aria-live character echo): Removed aria-live="assertive" from
every line div in domline.ts. This was causing screen readers to
announce every character typed, overriding users' keyboard echo
settings. The attribute was added in PR #5149 for JAWS compatibility
but aria-live on individual contenteditable lines is a misuse.
Also added .sr-only CSS utility class for visually hidden content.
Fixes#6581, #7255, #5695
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: Escape closes gritters first, only exits editor if nothing to dismiss
If gritter popups are visible, Escape closes them and keeps focus in
the editor. Only when there are no popups does Escape move focus to
the toolbar for keyboard trap escape.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address Qodo review — keyboard hint in iframe, aria-readonly
- Move keyboard hint (Escape/Alt+F9) inside the inner iframe with
aria-describedby so screen readers announce it when focusing the
editor. Previously it was on the outer editorcontainer which is a
different document context.
- Set aria-readonly on the editor body when in readonly mode so screen
readers correctly convey editability state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: consecutive numbering fails after indented sub-bullets
The applyNumberList() function in renumberList() checked
listType[0] === 'indent' but after regex exec, listType[0] is the
full match (e.g., "indent1"), never just "indent". Changed to
listType[1] which is the capture group containing just the type name.
This caused indent-type lines to not be recognized during renumbering,
breaking the numbering sequence when numbered lists followed indented
content.
Fixes#5718
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: assert sub-bullet indent and remove redundant waitForTimeout
- Assert .list-bullet2 exists after Tab to verify indent precondition
- Remove waitForTimeout(500) since toHaveAttribute already waits 5s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: skip identity changesets during timeslider playback
When a pad's revision history contains an identity changeset (Z:N>0$,
representing no actual change), the timeslider playback would crash or
break because broadcast.ts tried to apply it via mutateAttributionLines
and mutateTextLines.
Now all three applyChangeset call sites in broadcast.ts check for
identity changesets using the existing isIdentity() helper and skip
them. This also prevents errors when compose() produces an identity
changeset from multiple revisions that cancel each other out.
Fixes: https://github.com/ether/etherpad-lite/issues/5214
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: move identity changeset check inside applyChangeset
Move the isIdentity() guard from the call sites into applyChangeset()
itself, so that identity changesets still advance currentRevision,
currentTime, slider position, and author UI — just skipping the
mutation (mutateAttributionLines/mutateTextLines). This prevents the
timeslider from getting stuck on a stale revision when an identity
changeset is encountered.
Also removes unused `identity` import.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: improve timeslider identity changeset test coverage
- Verify slider position advances during playback (confirms revisions
including identity changesets are processed, not skipped)
- Scrub through every revision individually instead of just rev 0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: timeslider playback test starts from rev 0
The test was starting playback from the latest revision, so the slider
had nowhere to advance — causing the position assertion to fail in CI.
Now navigates to #0 first so playback progresses through all revisions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove stale identity-skip comment from goToRevision
The isIdentity() check was moved inside applyChangeset() but the old
comment remained at the call sites, creating a misleading code/comment
mismatch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: suppress internal error details from users in production mode
In production mode (NODE_ENV=production), the client-side error handler
now shows a generic "reload the page" message with just the ErrorId
instead of leaking internal details like error messages, file paths,
line numbers, stack traces, and user agent strings.
In development mode, the full error details are still shown for
debugging.
The basic_error_handler (pre-initialization errors) now always shows a
generic message and logs details to the console instead of displaying
them in the DOM.
The server-side jserror endpoint still receives full error details for
server-side logging — only the user-facing display is suppressed.
Fixes: https://github.com/ether/etherpad-lite/issues/5765
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: secure-by-default error sanitization and dedup fixes
Address Qodo review concerns:
- Flip mode check to `!== 'development'` (secure by default) so errors
before CLIENT_VARS handshake hide internal details
- Default server-side mode to 'development' when NODE_ENV is unset
- Replace DOM-based gritter dedup with in-memory Set so dedup works in
production mode (where .error-msg element is absent)
- Add Playwright regression tests for error sanitization
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: revert server-side mode default to preserve secure-by-default
Don't default mode to 'development' when NODE_ENV is unset — that would
defeat the client-side secure-by-default check. Let mode be undefined so
the client's `!== 'development'` check correctly hides error details.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: report early page load errors to server via sendBeacon
basic_error_handler now sends error details to ../jserror using
navigator.sendBeacon with FormData, matching the format expected by
the server's Formidable parser. Includes an errorId shown to the user
for correlation with server logs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* revert: restore original basic_error_handler with full error details
The basic_error_handler runs before the pad framework loads, so it
should show full details to help developers debug bootstrap failures.
Removes the sendBeacon reporting and error sanitization added in the
previous commit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PageDown was broken — it moved the caret to the last visible line of
the current viewport instead of advancing by one page. This caused it
to get "stuck" at the bottom of the viewport.
The old code set the caret to oldVisibleLineRange[1] - 1 (the last
visible line), which was essentially a no-op for scrolling. The fix
mirrors the PageUp logic: advance/retreat by numberOfLinesInViewport.
Also simplified the clamping logic for both selStart and selEnd.
Fixes: https://github.com/ether/etherpad-lite/issues/6710
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When an ordered list followed directly after an unordered list (no blank
line between), all OL items showed "1" instead of incrementing. This was
because renumberList's applyNumberList function counted bullet items in
the position counter, so the first OL item got start=3 (after 2 bullet
items) instead of start=1, preventing the CSS counter-reset class from
being applied.
The fix resets the position counter when the list type changes at the
same level (e.g., bullet -> number).
Fixes: https://github.com/ether/etherpad-lite/issues/5160
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: wait for server confirmation before navigating after pad delete
The delete pad handler navigated to '/' immediately after sending the
PAD_DELETE message. Firefox (and some mobile Chrome) would close the
WebSocket before the message reached the server, causing the delete to
silently fail.
Now the client waits for the server's {disconnect: 'deleted'} response
before navigating. Also awaits pad.remove() on the server side to
ensure the operation completes before the response is sent.
Fixes: https://github.com/ether/etherpad-lite/issues/7306
Fixes: https://github.com/ether/etherpad-lite/issues/7311
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: handle non-creator delete and add timeout fallback
- Listen for 'shout' event to show error when non-creator tries to
delete (server sends shoutMessage instead of deleting)
- Add 5-second timeout fallback in case the server doesn't respond
(socket dropped, server crashed, etc.)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When HTML containing <li> elements without a wrapping <ul> or <ol> is
pasted (e.g., from ChatGPT), the contentcollector crashes with
"TypeError: lineAttributes.list is undefined" because it assumes
_enterList() was already called by a parent list element.
The fix defaults bare <li> elements to bullet1 list type and properly
sets oldListTypeOrNull so the list state is cleaned up after the <li>
is processed. Also guards the .indexOf() call on lineAttributes.list.
Fixes: https://github.com/ether/etherpad-lite/issues/6665
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Enforce 2-space indentation across codebase
Convert all 4-space indented source files to 2-space to match
.editorconfig and project contributor guidelines.
74 files converted: admin UI components, type definitions, security
modules, test files, helpers, and utilities.
No functional changes — 2882 insertions, 2882 deletions (pure
whitespace).
Fixes#7353
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Limit admin tests to chromium and firefox
Webkit is already tested in the dedicated frontend-tests workflow.
Running it again in admin tests causes flaky failures due to slow
socket connections and external API timeouts on webkit CI runners.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix flaky undo keypress test
Each Ctrl+Z may only undo one keystroke depending on how Etherpad
batches undo operations (varies between dev and prod mode). Loop
Ctrl+Z presses until content is restored instead of assuming one
press undoes everything.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix flaky admin restart test causing cascading failures
restartEtherpad used a hardcoded 500ms wait which wasn't enough for
the server to restart on slow CI. Subsequent tests got
ERR_CONNECTION_REFUSED because the server was still down.
- Poll the server until it responds instead of hardcoded timeout
- Re-login after restart since the session cookie is lost
- Remove unnecessary waitForTimeout(5000) at end of test
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Speed up restart poll: accept any response, poll at 500ms
The previous check required response.ok() (2xx) but the server
returns redirects (3xx) which caused the loop to run all 30 seconds.
Accept any non-zero status and reduce poll interval to 500ms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix loginToAdmin: navigate to /admin/login directly
The admin SPA only shows the login form at /admin/login. Navigating to
/admin/ loads the HomePage route which doesn't have the login fields.
After a server restart the session is lost, but the SPA doesn't
automatically redirect to /admin/login, causing the test to timeout
waiting for input[name="username"].
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix plugin search test: wait for results, increase timeout
The search is debounce-triggered (500ms), not Enter-triggered. The
toHaveCount(1) check passed on the "not found" row before results
loaded. Removed the Enter press and count check, increased timeout
to 30s since the search depends on an external API call to
static.etherpad.org.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix plugin search: normalize numeric keys from registry
The plugin registry at static.etherpad.org/plugins.json changed format
from {ep_name: {data}} to {index: {name: "ep_name", data}}. The search
code iterated keys expecting plugin names starting with "ep_", but got
numeric keys like "41", skipping all plugins. Normalize the data after
fetching to use plugin names as keys.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix frontend test failures across all browsers
- Fix home button using fragile relative URL (window.location.href +
"/../..") that WebKit doesn't resolve correctly. Use
window.location.origin instead.
- Wait for #editorcontainer.initialized in goToNewPad/goToPad/
appendQueryParams so toolbar, chat, and cookie handlers are fully
set up before tests interact with them.
- Clear cookies in chat test beforeEach to prevent chatAndUsers cookie
from prior tests disabling the sticky chat checkbox.
- Wait for navigation to complete in editbar home button test.
Fixes#7405
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Run frontend tests on pull requests
Playwright runs locally and doesn't need Sauce Labs secrets, so
there's no reason to limit frontend tests to push events only.
Also remove stale Sauce Labs references from workflow names/comments.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix sticky chat test: use click() instead of check()/uncheck()
The stickToScreen() handler manages checkbox state internally with its
own toggle logic and a setTimeout. Playwright's check()/uncheck()
methods verify state after clicking, but race with the async toggle,
causing "Clicking the checkbox did not change its state" errors.
Using click() avoids this — the waitForSelector calls already verify
the final state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix sticky chat handler and reduce parallel workers
- Remove force:true from sticky chat checkbox clicks — it can bypass
jQuery event handlers preventing stickToScreen() from firing.
- Wait for chatbox stickyChat class instead of checkbox state, since
stickToScreen() manages the checkbox asynchronously via setTimeout.
- Reduce workers from 5 to 2 to avoid overloading the single Etherpad
server instance, which causes goToNewPad timeouts on CI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Clean up workflows: remove Sauce Labs, load test push-only
- Remove all Sauce Labs references (steps, comments, secrets) from
frontend test workflows — Playwright replaced Sauce Labs
- Remove unused set-output steps and GIT_HASH exports
- Remove stale commented-out code from admin tests
- Restrict load test to push events only (no need on PRs)
- Fix artifact names to not reference undefined matrix.node
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix sticky chat test: click label instead of checkbox
The label element intercepts pointer events on the checkbox (reported
by Webkit). On Chrome/Firefox the checkbox is "not stable" due to
animations. Clicking the label is how a real user interacts with it
and properly triggers the jQuery click handler.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix home button to preserve subpath installations
Use URL API to resolve '../..' relative to current URL instead of
hardcoding origin + '/'. This preserves any configured base path
(e.g. /etherpad) for reverse-proxy installations.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove the 2020 workaround that disabled nice-select on Safari due to
a position:fixed + overflow:hidden rendering bug. This bug has been
fixed in modern WebKit, and disabling nice-select meant Safari/WebKit
users got native selects while tests expected the custom dropdowns,
causing all font_type and language tests to fail on webkit.
Fixes#7405
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: started with implementation
* chore: finished index page
* chore: started with double sided modal
* chore: continue
* chore: completed implementation of transfer token
* chore: fixed typescript checks