diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d1a59e..45f9be5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,7 +67,10 @@ jobs: - run: pnpm install --frozen-lockfile - name: Install Playwright browsers - run: pnpm dlx playwright install --with-deps chromium + # Use the project-pinned Playwright (pnpm exec), not `pnpm dlx` which + # fetches the latest Playwright and installs mismatched browser + # revisions, leaving the pinned version's binary missing. + run: pnpm exec playwright install --with-deps chromium - name: Run tests run: pnpm test diff --git a/src/EpButton.ts b/src/EpButton.ts index 4c592c6..f67c004 100644 --- a/src/EpButton.ts +++ b/src/EpButton.ts @@ -20,6 +20,10 @@ export class EpButton extends LitElement { line-height: 1.5; border: none; outline: none; + /* Reset the UA button background so it doesn't leak through variants + that set their own (default/ghost/icon were showing the browser's + light ButtonFace, which looked broken in dark themes). */ + background: transparent; display: inline-flex; width: 100%; height: 100%; @@ -51,21 +55,21 @@ export class EpButton extends LitElement { background: var(--bg-soft-color, #f2f3f4); } - /* Primary */ + /* Primary — matches Etherpad colibris .btn-primary: primary-coloured + background with bg-coloured (white) text. */ :host([variant="primary"]) button { - background: var(--text-color, #586a69); - color: var(--primary-color, #64d29b); + background: var(--primary-color, #64d29b); + color: var(--bg-color, #ffffff); border: none; - transition: .2s background-color 0.15s ease, border-color 0.15s ease, opacity 0.15s ease; + transition: filter 0.15s ease, opacity 0.15s ease; } - :host([variant="primary"]) button:active { - box-shadow: var(--primary-button-active,inset 0 1px 12px rgba(0, 0, 0, 0.9)); - background: var(--primary-button-active, #444); + :host([variant="primary"]) button:hover { + filter: brightness(0.94); } - :host([variant="primary"]) button:hover { - background: var(--dark-color, #4a5d5c); + :host([variant="primary"]) button:active { + filter: brightness(0.88); } /* Ghost */ diff --git a/src/EpChatMessage.ts b/src/EpChatMessage.ts index 2ca1da8..ad382f1 100644 --- a/src/EpChatMessage.ts +++ b/src/EpChatMessage.ts @@ -4,44 +4,39 @@ import { customElement, property } from 'lit/decorators.js'; @customElement('ep-chat-message') export class EpChatMessage extends LitElement { static styles = css` + /* Matches Etherpad colibris chat: a plain message line (#chattext p), + padding 4px 10px, bold author, muted inline time, then the text. No + bubbles — Etherpad does not style own vs other messages differently. */ :host { --ep-font: var(--main-font-family, Quicksand, Cantarell, "Open Sans", "Helvetica Neue", sans-serif); display: block; font-family: var(--ep-font); font-size: 14px; + line-height: 1.5; color: var(--text-color, #485365); - padding: 6px 10px; + padding: 4px 10px; + word-wrap: break-word; } :host(:first-child) { padding-top: 10px; } :host(:last-child) { padding-bottom: 10px; } - .header { - display: flex; - align-items: baseline; - gap: 8px; - margin-bottom: 2px; - } - .author { - font-weight: 700; - font-size: 13px; + font-weight: bold; } .time { font-size: 11px; color: var(--text-soft-color, #576273); + margin: 0 4px 0 6px; } - .body { - line-height: 1.5; - word-wrap: break-word; - } + .body { display: inline; } + /* authorColors mode: Etherpad tints the whole message with the author + colour. Opt-in via the own flag to keep the default view plain. */ :host([own]) { background: var(--bg-soft-color, #f2f3f4); - border-radius: 4px; - margin: 2px 0; } `; @@ -52,13 +47,7 @@ export class EpChatMessage extends LitElement { render() { return html` -
- ${this.author} - ${this.time ? html`${this.time}` : ''} -
-
- -
+ ${this.author}${this.time ? html`${this.time}` : html``} `; } } diff --git a/src/EpCheckbox.ts b/src/EpCheckbox.ts index 421162c..9279fde 100644 --- a/src/EpCheckbox.ts +++ b/src/EpCheckbox.ts @@ -22,11 +22,17 @@ export class EpCheckbox extends LitElement { pointer-events: none; } + /* Etherpad colibris toggle: a light, outlined track (bg-soft fill with a + text-soft border) that switches to a primary-coloured border when + checked. */ .track { position: relative; - background: var(--middle-color, #d2d2d2); + box-sizing: border-box; + background: var(--bg-soft-color, #f2f3f4); + border: 2px solid var(--text-soft-color, #576273); border-radius: 10px; - transition: background 0.2s ease; + opacity: 0.7; + transition: border-color 0.2s ease, opacity 0.2s ease; flex-shrink: 0; } @@ -41,20 +47,25 @@ export class EpCheckbox extends LitElement { } :host([checked]) .track { - background: var(--text-color, #64d29b); + background: transparent; + border-color: var(--primary-color, #64d29b); + opacity: 1; } - + .thumb { position: absolute; top: 50%; width: 16px; height: 16px; border-radius: 50%; - background: white; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); - transition: transform 0.2s ease; + background: var(--text-soft-color, #576273); + transition: transform 0.2s ease, background 0.2s ease; transform: translateY(-50%); } + + :host([checked]) .thumb { + background: var(--primary-color, #64d29b); + } :host([variant="default"]) .thumb { left: 2px; diff --git a/src/EpColorPicker.ts b/src/EpColorPicker.ts index 8b17812..4db768f 100644 --- a/src/EpColorPicker.ts +++ b/src/EpColorPicker.ts @@ -27,9 +27,12 @@ const isLightColor = (color: string): boolean => { } }; +// Etherpad's author colour palette (the pastel tints from +// AuthorManager.getColorPalette) — the same swatches Etherpad offers when +// picking your author colour. const DEFAULT_COLORS = [ - 'black', 'red', 'green', 'blue', 'yellow', 'orange', - 'purple', 'pink', 'brown', 'gray', 'white', 'cyan', + '#ffc7c7', '#fff1c7', '#e3ffc7', '#c7ffd5', '#c7ffff', '#c7d5ff', '#e3c7ff', '#ffc7f1', + '#ffa8a8', '#ffe699', '#cfff9e', '#99ffb3', '#a3ffff', '#99b3ff', '#cc99ff', '#ff99e5', ]; @customElement('ep-color-picker') diff --git a/src/EpEditor.ts b/src/EpEditor.ts index 7729f37..94d0cdc 100644 --- a/src/EpEditor.ts +++ b/src/EpEditor.ts @@ -1,4 +1,4 @@ -import { LitElement, html, css } from 'lit'; +import { LitElement, html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { AceEditor } from './editor/AceEditor.js'; import type AttributePool from './editor/AttributePool.js'; @@ -16,60 +16,14 @@ import type AttributePool from './editor/AttributePool.js'; */ @customElement('ep-editor') export class EpEditor extends LitElement { - static styles = css` - :host { - display: block; - position: relative; - min-height: 100px; - } - - .ep-editor-container { - width: 100%; - height: 100%; - min-height: inherit; - overflow: auto; - font-family: var(--ep-editor-font, monospace); - font-size: var(--ep-editor-font-size, 14px); - line-height: var(--ep-editor-line-height, 1.6); - color: var(--ep-editor-color, #333); - background: var(--ep-editor-bg, #fff); - padding: var(--ep-editor-padding, 8px 12px); - box-sizing: border-box; - outline: none; - white-space: pre-wrap; - word-wrap: break-word; - } - - .ep-editor-container:focus { - outline: none; - } - - .ep-editor-container .list-bullet1 { list-style-type: disc; } - .ep-editor-container .list-bullet2 { list-style-type: circle; } - .ep-editor-container .list-bullet3 { list-style-type: square; } - .ep-editor-container .list-bullet4 { list-style-type: disc; } - .ep-editor-container .list-number1 { list-style-type: decimal; } - .ep-editor-container .list-number2 { list-style-type: lower-alpha; } - .ep-editor-container .list-number3 { list-style-type: lower-roman; } - .ep-editor-container .list-number4 { list-style-type: decimal; } - - .ep-editor-container ul, .ep-editor-container ol { - padding-left: 1.5em; - margin: 0; - } - - .ep-editor-container .tag\\:b, .ep-editor-container b { font-weight: bold; } - .ep-editor-container .tag\\:i, .ep-editor-container i { font-style: italic; } - .ep-editor-container .tag\\:u, .ep-editor-container u { text-decoration: underline; } - .ep-editor-container .tag\\:s, .ep-editor-container s { text-decoration: line-through; } - - .ep-editor-container a { color: var(--ep-editor-link-color, #0366d6); text-decoration: underline; } - - :host([readonly]) .ep-editor-container { - opacity: 0.85; - cursor: default; - } - `; + // Render into the light DOM, NOT a shadow root. Etherpad's Ace engine reads + // and restores the caret via the document Selection API, which does not work + // reliably across a shadow boundary (the caret retargets to the host and + // every keystroke inserts at offset 0, reversing the text). In light DOM the + // engine uses the standard, well-tested selection path. + protected createRenderRoot() { + return this; + } /** Initial text content for the editor. */ @property({ type: String }) content = ''; @@ -95,7 +49,7 @@ export class EpEditor extends LitElement { // ── Lifecycle ────────────────────────────────────────────── protected firstUpdated() { - const container = this.shadowRoot!.querySelector('.ep-editor-container') as HTMLElement; + const container = this.renderRoot.querySelector('.ep-editor-container') as HTMLElement; if (!container) return; this._editor = new AceEditor(container); @@ -294,6 +248,41 @@ export class EpEditor extends LitElement { protected render() { return html` +
= { success: ``, - error: ``, + error: ``, info: ``, }; @@ -61,7 +61,7 @@ export class EpToastItem extends LitElement { } :host([type="success"]) .toast { border-left-color: var(--primary-color, #64d29b); } - :host([type="error"]) .toast { border-left-color: #d9534f; } + :host([type="error"]) .toast { border-left-color: #d1242f; } :host([type="info"]) .toast { border-left-color: var(--dark-color, #576273); } .icon { flex-shrink: 0; width: 16px; height: 16px; margin-top: 2px; } diff --git a/src/editor/AceEditor.ts b/src/editor/AceEditor.ts index 50015a9..c7fd1b0 100644 --- a/src/editor/AceEditor.ts +++ b/src/editor/AceEditor.ts @@ -2068,13 +2068,38 @@ export class AceEditor { return this.targetDoc.getSelection(); } + // Obtain the live selection range. Inside a shadow root, getRangeAt(0) + // retargets the range to the shadow host (losing the real caret, which then + // reads as offset 0 — every keystroke would insert at the document start). + // getComposedRanges({shadowRoots}) returns a range that descends into the + // shadow tree, so we use it whenever the editor lives in a shadow root. + private getSelectionRange(browserSelection: any): { + startContainer: Node; startOffset: number; endContainer: Node; endOffset: number; + } | null { + if (this.rootNode instanceof ShadowRoot && + typeof browserSelection.getComposedRanges === 'function') { + const ranges = browserSelection.getComposedRanges({ shadowRoots: [this.rootNode] }); + if (ranges && ranges.length > 0) { + const r = ranges[0]; + if (r.startContainer) return r; + } + } + if (browserSelection.rangeCount === 0) return null; + return browserSelection.getRangeAt(0); + } + private getSelection(): any { const browserSelection = this.getDocSelection(); - if (!browserSelection || browserSelection.type === 'None' || - browserSelection.rangeCount === 0) { + if (!browserSelection) return null; + // In shadow DOM the legacy Selection reports type 'None' / rangeCount 0 + // even when the caret is in the shadow tree, but getComposedRanges still + // resolves it — so only apply these guards outside a shadow root. + if (!(this.rootNode instanceof ShadowRoot) && + (browserSelection.type === 'None' || browserSelection.rangeCount === 0)) { return null; } - const range = browserSelection.getRangeAt(0); + const range = this.getSelectionRange(browserSelection); + if (!range) return null; const isInBody = (n: Node | null): boolean => { while (n) {