web components (author,
+// time, and `own` are reflected attributes; the message body lives in the
+// default slot). The old helpers looked for elements from the pre-
+// webcomponent chat UI — every selector here needed to move to the new tag.
export const getCurrentChatMessageCount = async (page: Page) => {
- return await page.locator('#chattext').locator('p').count()
+ return await page.locator('#chattext').locator('ep-chat-message').count()
}
export const getChatUserName = async (page: Page) => {
- return await page.locator('#chattext')
- .locator('p')
- .locator('b')
- .innerText()
+ return (await page.locator('#chattext')
+ .locator('ep-chat-message')
+ .first()
+ .getAttribute('author')) ?? ''
}
export const getChatMessage = async (page: Page) => {
+ // The slotted body contains the message text. textContent on the host
+ // returns the combined light-DOM children (author/time live in shadow DOM).
return (await page.locator('#chattext')
- .locator('p')
- .textContent({}))!
- .split(await getChatTime(page))[1]
+ .locator('ep-chat-message')
+ .first()
+ .textContent()) ?? ''
}
export const getChatTime = async (page: Page) => {
- return await page.locator('#chattext')
- .locator('p')
- .locator('.time')
- .innerText()
+ return (await page.locator('#chattext')
+ .locator('ep-chat-message')
+ .first()
+ .getAttribute('time')) ?? ''
}
export const sendChatMessage = async (page: Page, message: string) => {
@@ -69,7 +134,7 @@ export const sendChatMessage = async (page: Page, message: string) => {
await chatInput.fill(message)
await page.keyboard.press('Enter')
if (message === "") return
- await expect(page.locator('#chattext').locator('p')).toHaveCount(currentChatCount + 1, { timeout: 10000 })
+ await expect(page.locator('#chattext').locator('ep-chat-message')).toHaveCount(currentChatCount + 1, { timeout: 10000 })
}
export const isChatBoxShown = async (page: Page) => {
@@ -106,19 +171,13 @@ export const appendQueryParams = async (page: Page, queryParameters: Record {
- // Wait for the outer frame
- await page.waitForSelector('iframe[name="ace_outer"]', { timeout, state: 'attached' });
-
- // Use frameLocator to wait for inner frame content — avoids polling loop
- const innerFrame = page.frameLocator('iframe[name="ace_outer"]')
- .frameLocator('iframe[name="ace_inner"]');
- await innerFrame.locator('#innerdocbody').waitFor({ state: 'visible', timeout });
+ await page.locator('#innerdocbody').waitFor({ state: 'visible', timeout });
};
const navigateToPad = async (page: Page, padId: string) => {
@@ -153,11 +212,7 @@ export const goToPad = async (page: Page, padId: string) => {
}
export const clearPadContent = async (page: Page) => {
- const innerFrame = page.frame('ace_inner');
- if (!innerFrame) {
- throw new Error('Could not find ace_inner frame');
- }
- const body = innerFrame.locator('#innerdocbody');
+ const body = page.locator('#innerdocbody');
await body.click();
await selectAllText(page);
await page.keyboard.press('Backspace');
@@ -166,11 +221,7 @@ export const clearPadContent = async (page: Page) => {
}
export const writeToPad = async (page: Page, text: string) => {
- const innerFrame = page.frame('ace_inner');
- if (!innerFrame) {
- throw new Error('Could not find ace_inner frame');
- }
- const body = innerFrame.locator('#innerdocbody');
+ const body = page.locator('#innerdocbody');
await body.click();
await page.keyboard.type(text, { delay: 5 });
}
diff --git a/playwright/helper/settingsHelper.ts b/playwright/helper/settingsHelper.ts
index 5483cd00..351071d5 100644
--- a/playwright/helper/settingsHelper.ts
+++ b/playwright/helper/settingsHelper.ts
@@ -1,4 +1,5 @@
import {expect, Page} from "@playwright/test";
+import {setEpCheckbox} from "./padHelper";
export const isSettingsShown = async (page: Page) => {
const classes = await page.locator('#settings').getAttribute('class')
@@ -9,6 +10,16 @@ export const showSettings = async (page: Page) => {
if (await isSettingsShown(page)) return
await page.locator("button[class~='buttonicon-settings']").click()
await expect(page.locator('#settings')).toHaveClass(/popup-show/, { timeout: 5000 })
+ // The popup's scale(0.7) → none transform animates over 300ms. While
+ // that transform is non-identity it forms a containing block for any
+ // position: fixed descendants — which includes 's
+ // content-wrapper. Clicking a dropdown item before the transform
+ // settles positions the content relative to the still-transforming
+ // popup instead of the viewport, and Playwright can time out waiting
+ // for a stable, visible item. Wait for the transition to complete.
+ await page.locator('#settings').evaluate((el) =>
+ Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished.catch(() => {})))
+ );
}
export const hideSettings = async (page: Page) => {
@@ -17,18 +28,16 @@ export const hideSettings = async (page: Page) => {
await expect(page.locator('#settings')).not.toHaveClass(/popup-show/, { timeout: 5000 })
}
+// #options-stickychat is an , so Playwright's native
+// .isChecked()/.check()/.uncheck()/toBeChecked() do not apply.
export const enableStickyChatviaSettings = async (page: Page) => {
const stickyChat = page.locator('#options-stickychat')
await stickyChat.waitFor({ state: 'visible', timeout: 5000 });
- if (await stickyChat.isChecked()) return
- await stickyChat.check({ force: true })
- await expect(stickyChat).toBeChecked({ timeout: 5000 });
+ await setEpCheckbox(stickyChat, true);
}
export const disableStickyChat = async (page: Page) => {
const stickyChat = page.locator('#options-stickychat')
await stickyChat.waitFor({ state: 'visible', timeout: 5000 });
- if (!await stickyChat.isChecked()) return
- await stickyChat.uncheck({ force: true })
- await expect(stickyChat).not.toBeChecked({ timeout: 5000 });
+ await setEpCheckbox(stickyChat, false);
}
diff --git a/playwright/specs/bold.spec.ts b/playwright/specs/bold.spec.ts
index 22d5b68f..ced6a0bf 100644
--- a/playwright/specs/bold.spec.ts
+++ b/playwright/specs/bold.spec.ts
@@ -12,10 +12,7 @@ test.describe('bold button', ()=>{
await writeToPad(page, "Hi Etherpad");
await page.waitForTimeout(300);
- // Get the inner frame directly
- const innerFrame = page.frame('ace_inner');
- if (!innerFrame) throw new Error('Could not find ace_inner frame');
- const body = innerFrame.locator('#innerdocbody');
+ const body = page.locator('#innerdocbody');
// Triple-click to select the line
await body.locator('div').first().click({ clickCount: 3 });
@@ -38,10 +35,7 @@ test.describe('bold button', ()=>{
await writeToPad(page, "Hi Etherpad");
await page.waitForTimeout(300);
- // Get the inner frame directly
- const innerFrame = page.frame('ace_inner');
- if (!innerFrame) throw new Error('Could not find ace_inner frame');
- const body = innerFrame.locator('#innerdocbody');
+ const body = page.locator('#innerdocbody');
// Triple-click to select the line
await body.locator('div').first().click({ clickCount: 3 });
diff --git a/playwright/specs/change_user_color.spec.ts b/playwright/specs/change_user_color.spec.ts
index bc6b609a..9e2bbcc5 100644
--- a/playwright/specs/change_user_color.spec.ts
+++ b/playwright/specs/change_user_color.spec.ts
@@ -1,5 +1,5 @@
import {expect, test} from "@playwright/test";
-import {goToNewPad, sendChatMessage, showChat} from "../helper/padHelper";
+import {goToNewPad, sendChatMessage, setEpCheckbox, showChat} from "../helper/padHelper";
test.beforeEach(async ({page}) => {
await goToNewPad(page);
@@ -59,9 +59,7 @@ test.describe('change user color', function () {
test('Own user color is shown when you enter a chat', async function ({page}) {
const colorOption = page.locator('#options-colorscheck');
- if (!(await colorOption.isChecked())) {
- await colorOption.check();
- }
+ await setEpCheckbox(colorOption, true);
// click on the settings button to make settings visible
const $userButton = page.locator('.buttonicon-showusers');
@@ -88,16 +86,23 @@ test.describe('change user color', function () {
await showChat(page)
await sendChatMessage(page, 'O hi');
- // wait until the chat message shows up
- const chatP = page.locator('#chattext').locator('p')
- const chatText = await chatP.innerText();
+ // wait until the chat message shows up — chat now renders as
+ // webcomponents rather than elements.
+ const chatMsg = page.locator('#chattext').locator('ep-chat-message').first()
+ await expect(chatMsg).toBeVisible({timeout: 10000});
+ const chatText = (await chatMsg.textContent()) ?? '';
expect(chatText).toContain('O hi');
- const color = await chatP.evaluate((el) => {
- return window.getComputedStyle(el).getPropertyValue('background-color');
- }, chatText);
+ // The author color is rendered inside the shadow DOM on the
+ // `.author` span via inline `color: ${authorColor}`. Read it from
+ // the shadow root.
+ const authorColor = await chatMsg.evaluate((el) => {
+ const span = el.shadowRoot?.querySelector('.author') as HTMLElement | null;
+ if (!span) return '';
+ return window.getComputedStyle(span).getPropertyValue('color');
+ });
- expect(color).toBe(testColorRGB);
+ expect(authorColor).toBe(testColorRGB);
});
});
diff --git a/playwright/specs/change_user_name.spec.ts b/playwright/specs/change_user_name.spec.ts
index 37877240..605ec337 100644
--- a/playwright/specs/change_user_name.spec.ts
+++ b/playwright/specs/change_user_name.spec.ts
@@ -29,7 +29,12 @@ test('Own user name is shown when you enter a chat', async ({page})=> {
await showChat(page);
await sendChatMessage(page,chatMessage);
- const chatText = await page.locator('#chattext').locator('p').innerText();
- expect(chatText).toContain('😃')
- expect(chatText).toContain(chatMessage)
+ // Chat renders as webcomponents: the author name lives
+ // on the `author` attribute, and the message body is the slotted text.
+ const chatMsg = page.locator('#chattext').locator('ep-chat-message').first();
+ await expect(chatMsg).toBeVisible({timeout: 10000});
+ const author = (await chatMsg.getAttribute('author')) ?? '';
+ const body = (await chatMsg.textContent()) ?? '';
+ expect(author).toContain('😃');
+ expect(body).toContain(chatMessage);
});
diff --git a/playwright/specs/chat.spec.ts b/playwright/specs/chat.spec.ts
index a3a8ad75..bb959cf9 100644
--- a/playwright/specs/chat.spec.ts
+++ b/playwright/specs/chat.spec.ts
@@ -31,10 +31,14 @@ test('opens chat, sends a message, makes sure it exists on the page and hides ch
const time = await getChatTime(page)
const chatMessage = await getChatMessage(page)
- expect(username).toBe('unnamed:');
+ // After the WebComponents migration chat messages render as
+ // with `author` and `time` as attributes. No trailing colon on the username,
+ // and the message body has no leading space — those artifacts were from the
+ // previous name: … body
layout.
+ expect(username).toBe('unnamed');
const regex = new RegExp('^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$');
expect(time).toMatch(regex);
- expect(chatMessage).toBe(" "+chatValue);
+ expect(chatMessage).toBe(chatValue);
})
test("makes sure that an empty message can't be sent", async function ({page}) {
@@ -53,10 +57,10 @@ test("makes sure that an empty message can't be sent", async function ({page}) {
const time = await getChatTime(page);
const chatMessage = await getChatMessage(page);
- expect(username).toBe('unnamed:');
+ expect(username).toBe('unnamed');
const regex = new RegExp('^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$');
expect(time).toMatch(regex);
- expect(chatMessage).toBe(" "+chatValue);
+ expect(chatMessage).toBe(chatValue);
});
test('makes chat stick to right side of the screen via settings, remove sticky via settings, close it', async ({page}) =>{
diff --git a/playwright/specs/collab_client.spec.ts b/playwright/specs/collab_client.spec.ts
index 0d91713d..f25593c5 100644
--- a/playwright/specs/collab_client.spec.ts
+++ b/playwright/specs/collab_client.spec.ts
@@ -15,9 +15,7 @@ test.describe('Messages in the COLLABROOM', function () {
await clearPadContent(page1);
await writeToPad(page1, 'Hello from User 1');
- const innerFrame1 = page1.frame('ace_inner');
- if (!innerFrame1) throw new Error('Could not find ace_inner frame');
- const body1 = innerFrame1.locator('#innerdocbody');
+ const body1 = page1.locator('#innerdocbody');
// Verify User 1's content
await expect(body1.locator('div').first()).toContainText('Hello from User 1');
@@ -27,9 +25,7 @@ test.describe('Messages in the COLLABROOM', function () {
const page2 = await context2.newPage();
await goToPad(page2, padId);
- const innerFrame2 = page2.frame('ace_inner');
- if (!innerFrame2) throw new Error('Could not find ace_inner frame');
- const body2 = innerFrame2.locator('#innerdocbody');
+ const body2 = page2.locator('#innerdocbody');
// User 2 should see User 1's text
await expect(body2.locator('div').first()).toContainText('Hello from User 1', { timeout: 20000 });
diff --git a/playwright/specs/embed_value.spec.ts b/playwright/specs/embed_value.spec.ts
index c22f70dc..863b7362 100644
--- a/playwright/specs/embed_value.spec.ts
+++ b/playwright/specs/embed_value.spec.ts
@@ -1,5 +1,5 @@
import {expect, Page, test} from "@playwright/test";
-import {goToNewPad} from "../helper/padHelper";
+import {goToNewPad, setEpCheckbox} from "../helper/padHelper";
test.beforeEach(async ({ page })=>{
// create a new pad before each test run
@@ -102,11 +102,7 @@ test.describe('embed links', function () {
await page.waitForTimeout(200);
const readonlyCheckbox = page.locator('#readonlyinput')
- await readonlyCheckbox.click({
- force: true
- })
- // Wait for the checkbox to be checked
- await expect(readonlyCheckbox).toBeChecked({ timeout: 5000 });
+ await setEpCheckbox(readonlyCheckbox, true);
// get the link of the share field + the actual pad url and compare them
const shareLink = await page.locator('#linkinput').inputValue()
@@ -122,15 +118,8 @@ test.describe('embed links', function () {
await shareButton.click()
await page.waitForTimeout(200);
- // check read only checkbox, a bit hacky
const readonlyCheckbox = page.locator('#readonlyinput')
- await readonlyCheckbox.click({
- force: true
- })
-
- // Wait for the checkbox to be checked
- await expect(readonlyCheckbox).toBeChecked({ timeout: 5000 });
-
+ await setEpCheckbox(readonlyCheckbox, true);
// get the link of the share field + the actual pad url and compare them
const embedCode = await page.locator('#embedinput').inputValue()
diff --git a/playwright/specs/enter.spec.ts b/playwright/specs/enter.spec.ts
index 48f6dfd7..06af19ba 100644
--- a/playwright/specs/enter.spec.ts
+++ b/playwright/specs/enter.spec.ts
@@ -13,9 +13,7 @@ test.describe('enter keystroke', function () {
await clearPadContent(page);
await writeToPad(page, 'Test Line');
- const innerFrame = page.frame('ace_inner');
- if (!innerFrame) throw new Error('Could not find ace_inner frame');
- const body = innerFrame.locator('#innerdocbody');
+ const body = page.locator('#innerdocbody');
// Verify we have one line with content
await expect(body.locator('div').first()).toHaveText('Test Line');
@@ -38,9 +36,7 @@ test.describe('enter keystroke', function () {
test('enter is always visible after event', async function ({page}) {
await clearPadContent(page);
- const innerFrame = page.frame('ace_inner');
- if (!innerFrame) throw new Error('Could not find ace_inner frame');
- const body = innerFrame.locator('#innerdocbody');
+ const body = page.locator('#innerdocbody');
// Start with 1 line
await expect(body.locator('div')).toHaveCount(1);
diff --git a/playwright/specs/font_type.spec.ts b/playwright/specs/font_type.spec.ts
index 49a4d13f..f7de96ef 100644
--- a/playwright/specs/font_type.spec.ts
+++ b/playwright/specs/font_type.spec.ts
@@ -1,5 +1,5 @@
import {expect, Page, test} from "@playwright/test";
-import {goToNewPad} from "../helper/padHelper";
+import {goToNewPad, selectEpDropdownItem} from "../helper/padHelper";
import {showSettings} from "../helper/settingsHelper";
test.beforeEach(async ({ page })=>{
@@ -11,9 +11,7 @@ test.describe('font select', function () {
test.skip(({ browserName }) => browserName === 'webkit', 'Skipping on WebKit due to dropdown issues');
const getBodyFontFamily = async (page: Page) => {
- const innerFrame = page.frame('ace_inner');
- if (!innerFrame) throw new Error('Could not find ace_inner frame');
- const body = innerFrame.locator('#innerdocbody');
+ const body = page.locator('#innerdocbody');
return await body.evaluate((e) => {
return window.getComputedStyle(e).getPropertyValue("font-family").toLowerCase();
});
@@ -21,9 +19,7 @@ test.describe('font select', function () {
test('makes text RobotoMono', async function ({page}) {
await showSettings(page);
- const fontMenu = page.locator('#viewfontmenu');
- await fontMenu.selectOption('RobotoMono');
- await expect(fontMenu).toHaveValue('RobotoMono');
+ await selectEpDropdownItem(page, '#viewfontmenu', 'RobotoMono');
// Check if font changed to RobotoMono
await expect.poll(async () => {
@@ -33,12 +29,12 @@ test.describe('font select', function () {
test('resets to normal font type', async function ({page}) {
await showSettings(page);
- const fontMenu = page.locator('#viewfontmenu');
- await fontMenu.selectOption('RobotoMono');
- await expect(fontMenu).toHaveValue('RobotoMono');
+ await selectEpDropdownItem(page, '#viewfontmenu', 'RobotoMono');
+ await expect.poll(async () => {
+ return await getBodyFontFamily(page);
+ }).toContain('robotomono');
- await fontMenu.selectOption('');
- await expect(fontMenu).toHaveValue('');
+ await selectEpDropdownItem(page, '#viewfontmenu', '');
await expect.poll(async () => {
return await getBodyFontFamily(page);
diff --git a/playwright/specs/italic.spec.ts b/playwright/specs/italic.spec.ts
index 22a78f0e..ecfc23bd 100644
--- a/playwright/specs/italic.spec.ts
+++ b/playwright/specs/italic.spec.ts
@@ -14,10 +14,7 @@ test.describe('italic some text', function () {
await writeToPad(page, 'Foo')
await page.waitForTimeout(300);
- // Get the inner frame directly
- const innerFrame = page.frame('ace_inner');
- if (!innerFrame) throw new Error('Could not find ace_inner frame');
- const body = innerFrame.locator('#innerdocbody');
+ const body = page.locator('#innerdocbody');
// Triple-click to select the line
await body.locator('div').first().click({ clickCount: 3 });
@@ -43,10 +40,7 @@ test.describe('italic some text', function () {
await writeToPad(page, 'Foo')
await page.waitForTimeout(300);
- // Get the inner frame directly
- const innerFrame = page.frame('ace_inner');
- if (!innerFrame) throw new Error('Could not find ace_inner frame');
- const body = innerFrame.locator('#innerdocbody');
+ const body = page.locator('#innerdocbody');
// Triple-click to select the line
await body.locator('div').first().click({ clickCount: 3 });
diff --git a/playwright/specs/language.spec.ts b/playwright/specs/language.spec.ts
index 6f38bcfa..cbcde5ae 100644
--- a/playwright/specs/language.spec.ts
+++ b/playwright/specs/language.spec.ts
@@ -1,5 +1,5 @@
import {expect, Page, test} from "@playwright/test";
-import {goToNewPad} from "../helper/padHelper";
+import {goToNewPad, selectEpDropdownItem} from "../helper/padHelper";
test.beforeEach(async ({ page })=>{
await page.context().clearCookies();
@@ -8,7 +8,7 @@ test.beforeEach(async ({ page })=>{
const selectLanguage = async (page: Page, language: string) => {
const languageMenu = page.locator('#languagemenu');
- await page.waitForSelector('iframe[name="ace_outer"]');
+ await page.locator('#innerdocbody').waitFor({ state: 'visible' });
for (let i = 0; i < 3; i++) {
if (await languageMenu.isVisible()) break;
await page.locator("button[class~='buttonicon-settings']").click();
@@ -17,7 +17,7 @@ const selectLanguage = async (page: Page, language: string) => {
await expect(languageMenu).toBeVisible();
await Promise.all([
page.waitForLoadState('load'),
- languageMenu.selectOption(language),
+ selectEpDropdownItem(page, '#languagemenu', language),
]);
};
@@ -27,14 +27,12 @@ test.describe('Language select and change', function () {
test('makes text german', async function ({page}) {
await selectLanguage(page, 'de');
- await expect(page.locator('#languagemenu')).toHaveValue('de');
await expect(page.locator('html')).toHaveAttribute('lang', 'de');
});
test('makes text English', async function ({page}) {
await selectLanguage(page, 'de');
await selectLanguage(page, 'en');
- await expect(page.locator('#languagemenu')).toHaveValue('en');
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
});
@@ -46,14 +44,12 @@ test.describe('Language select and change', function () {
test('changes direction when picking an ltr lang', async function ({page}) {
await selectLanguage(page, 'ar');
await selectLanguage(page, 'en');
- await expect(page.locator('#languagemenu')).toHaveValue('en');
await expect(page.locator('html')).toHaveAttribute('dir', 'ltr');
});
test('keeps selected language after reload', async function ({page}) {
await selectLanguage(page, 'de');
await goToNewPad(page);
- await expect(page.locator('#languagemenu')).toHaveValue('de');
await expect(page.locator('html')).toHaveAttribute('lang', 'de');
});
diff --git a/playwright/specs/ordered_list.spec.ts b/playwright/specs/ordered_list.spec.ts
index edf18da7..4fadc92c 100644
--- a/playwright/specs/ordered_list.spec.ts
+++ b/playwright/specs/ordered_list.spec.ts
@@ -58,6 +58,7 @@ test.describe('ordered_list.js', function () {
test('indent and de-indent list item with keypress', async function ({page}) {
const padBody = await getPadBody(page);
+ await clearPadContent(page)
// get the first text element out of the inner iframe
const $firstTextElement = padBody.locator('div').first();
@@ -68,6 +69,11 @@ test.describe('ordered_list.js', function () {
const $insertorderedlistButton = page.locator('.buttonicon-insertorderedlist')
await $insertorderedlistButton.click()
+ // Re-focus the editor: clicking the toolbar button moves focus off
+ // #innerdocbody, so a bare keyboard.press('Tab') would tab to the next
+ // toolbar button instead of triggering the editor's Tab handler.
+ await padBody.locator('div').first().click();
+ await page.keyboard.press('Home');
await page.keyboard.press('Tab')
await expect(padBody.locator('div').first().locator('.list-number2')).toHaveCount(1)
@@ -95,6 +101,13 @@ test.describe('ordered_list.js', function () {
const $insertorderedlistButton = page.locator('.buttonicon-insertorderedlist')
await $insertorderedlistButton.click()
+ // Re-focus the editor and place the caret on the list line: the toolbar
+ // button click applies a changeset that shifts rep.selStart to a stale
+ // position, so subsequent indent commands need a fresh selection inside
+ // the new list line.
+ await padBody.locator('div').first().click();
+ await page.keyboard.press('Home');
+
const $indentButton = page.locator('.buttonicon-indent')
await $indentButton.dblclick() // make it indented twice
diff --git a/playwright/specs/qr_code.spec.ts b/playwright/specs/qr_code.spec.ts
index 09195719..e57b509c 100644
--- a/playwright/specs/qr_code.spec.ts
+++ b/playwright/specs/qr_code.spec.ts
@@ -1,5 +1,5 @@
import {expect, Page, test} from "@playwright/test";
-import {goToNewPad} from "../helper/padHelper";
+import {goToNewPad, setEpCheckbox} from "../helper/padHelper";
test.beforeEach(async ({page}) => {
await goToNewPad(page);
@@ -44,10 +44,7 @@ test.describe('QR share popup', () => {
const qrLinkInput = page.locator('#qrcodelinkinput');
const qrImage = page.locator('#qrcodeimg');
- await qrReadonlyToggle.evaluate((element: HTMLInputElement) => {
- element.checked = true;
- element.dispatchEvent(new Event('click', {bubbles: true}));
- });
+ await setEpCheckbox(qrReadonlyToggle, true);
await expect(qrLinkInput).toHaveValue(new RegExp(`/${readOnlyId}$`));
await expect(qrImage).toHaveAttribute('src', `${page.url().split('?')[0]}/qr?readonly=true`);
await waitForQrImage(page);
diff --git a/playwright/specs/settings.spec.ts b/playwright/specs/settings.spec.ts
index 6ebd961f..95543d2f 100644
--- a/playwright/specs/settings.spec.ts
+++ b/playwright/specs/settings.spec.ts
@@ -1,11 +1,11 @@
import {expect, Page, test} from "@playwright/test";
-import {goToNewPad} from "../helper/padHelper";
+import {goToNewPad, setEpCheckbox} from "../helper/padHelper";
const settingsButton = "button[class~='buttonicon-settings']";
const ensureSettingsVisible = async (page: Page) => {
const settings = page.locator('#settings');
- await page.waitForSelector('iframe[name="ace_outer"]');
+ await page.locator('#innerdocbody').waitFor({ state: 'visible' });
for (let i = 0; i < 3; i++) {
const classes = await settings.getAttribute('class');
if (classes?.includes('popup-show')) return;
@@ -34,18 +34,16 @@ test.describe('settings popup and options', () => {
test('toggles line numbers visibility in editor gutter', async ({page}) => {
await ensureSettingsVisible(page);
const lineNumbersCheckbox = page.locator('#options-linenoscheck');
- const outerFrame = page.frame('ace_outer');
- if (!outerFrame) throw new Error('Could not find ace_outer frame');
- await lineNumbersCheckbox.uncheck({force: true});
+ await setEpCheckbox(lineNumbersCheckbox, false);
await expect.poll(async () => {
- return await outerFrame.locator('#sidediv').evaluate((node) =>
+ return await page.locator('#sidediv').evaluate((node) =>
node.parentElement?.classList.contains('line-numbers-hidden') ?? false);
}).toBe(true);
- await lineNumbersCheckbox.check({force: true});
+ await setEpCheckbox(lineNumbersCheckbox, true);
await expect.poll(async () => {
- return await outerFrame.locator('#sidediv').evaluate((node) =>
+ return await page.locator('#sidediv').evaluate((node) =>
node.parentElement?.classList.contains('line-numbers-hidden') ?? false);
}).toBe(false);
});
@@ -55,10 +53,10 @@ test.describe('settings popup and options', () => {
const colorsCheckbox = page.locator('#options-colorscheck');
const chatText = page.locator('#chattext');
- await colorsCheckbox.uncheck({force: true});
+ await setEpCheckbox(colorsCheckbox, false);
await expect(chatText).not.toHaveClass(/authorColors/);
- await colorsCheckbox.check({force: true});
+ await setEpCheckbox(colorsCheckbox, true);
await expect(chatText).toHaveClass(/authorColors/);
});
@@ -66,10 +64,10 @@ test.describe('settings popup and options', () => {
await ensureSettingsVisible(page);
const rtlCheckbox = page.locator('#options-rtlcheck');
- await rtlCheckbox.check({force: true});
+ await setEpCheckbox(rtlCheckbox, true);
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl');
- await rtlCheckbox.uncheck({force: true});
+ await setEpCheckbox(rtlCheckbox, false);
await expect(page.locator('html')).toHaveAttribute('dir', 'ltr');
});
});
diff --git a/playwright/specs/unordered_list.spec.ts b/playwright/specs/unordered_list.spec.ts
index e1cbfa22..c02b9a91 100644
--- a/playwright/specs/unordered_list.spec.ts
+++ b/playwright/specs/unordered_list.spec.ts
@@ -115,6 +115,13 @@ test.describe('unordered_list.js', function () {
const $insertunorderedlistButton = page.locator('.buttonicon-insertunorderedlist');
await $insertunorderedlistButton.click();
+ // Re-focus the editor and place the caret on the list line: the
+ // toolbar button click applies a changeset that shifts rep.selStart
+ // to a stale position, so the subsequent indent command needs a
+ // fresh selection inside the new list line.
+ await padBody.locator('div').first().click();
+ await page.keyboard.press('Home');
+
await page.locator('.buttonicon-indent').click();
await expect(padBody.locator('div').first().locator('.list-bullet2')).toHaveCount(1);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e279ea0a..1870760f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4,16 +4,13 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
-overrides:
- etherpad-webcomponents: 0.0.4
-
importers:
.:
dependencies:
etherpad-webcomponents:
- specifier: 0.0.4
- version: 0.0.4(@lit/reactive-element@2.1.2)(lit-element@4.2.2)(lit-html@3.3.2)(lit@3.3.2)
+ specifier: ^0.0.11
+ version: 0.0.11
devDependencies:
typescript:
specifier: ^5.6.3
@@ -119,8 +116,8 @@ importers:
specifier: 0.28.0
version: 0.28.0
etherpad-webcomponents:
- specifier: 0.0.4
- version: 0.0.4(@lit/reactive-element@2.1.2)(lit-element@4.2.2)(lit-html@3.3.2)(lit@3.3.2)
+ specifier: ^0.0.11
+ version: 0.0.11
typescript:
specifier: ^6.0.2
version: 6.0.2
@@ -853,13 +850,8 @@ packages:
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
- etherpad-webcomponents@0.0.4:
- resolution: {integrity: sha512-fvOJChHwmleqhkoTlxGx0/2BvoYUiWxIgA0APrYORHSR6ovgpA5VVLN5J+8YTO+FPGQQOVBgZVdZE69fydnlZg==}
- peerDependencies:
- '@lit/reactive-element': ^2.1.2
- lit: ^3.3.2
- lit-element: ^4.2.2
- lit-html: ^3.3.2
+ etherpad-webcomponents@0.0.11:
+ resolution: {integrity: sha512-4QrJuCAXHIAm7hxffAv4cIPaXesLLSd1rfJhpOvI8gaOCvjhMSJb9wDLItwjfCMnbTHkRPQTVdAZGwwqVkyM0Q==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
@@ -1704,7 +1696,7 @@ snapshots:
estree-walker@2.0.2: {}
- etherpad-webcomponents@0.0.4(@lit/reactive-element@2.1.2)(lit-element@4.2.2)(lit-html@3.3.2)(lit@3.3.2):
+ etherpad-webcomponents@0.0.11:
dependencies:
'@lit/reactive-element': 2.1.2
lit: 3.3.2
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 08d0f566..248a3489 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -3,6 +3,3 @@ packages:
- playwright
- ui
- plugins/*
-
-overrides:
- etherpad-webcomponents: 0.0.4
diff --git a/settings.template.json b/settings.template.json
index 33e11845..ed34d3bb 100644
--- a/settings.template.json
+++ b/settings.template.json
@@ -78,9 +78,11 @@
"trustProxy": false,
"cookie": {
"keyRotationInterval": 86400000,
+ "prefix": "",
"sameSite": "Lax",
"sessionLifetime": 864000000,
- "sessionRefreshInterval": 86400000
+ "sessionRefreshInterval": 86400000,
+ "sessionCleanup": true
},
"disableIPlogging": false,
"automaticReconnectionTimeout": 0,
@@ -105,7 +107,7 @@
},
"socketTransportProtocols": ["websocket", "polling"],
"socketIo": {
- "maxHttpBufferSize": 50000
+ "maxHttpBufferSize": 1048576
},
"loadTest": false,
"dumpOnUncleanExit": false,
diff --git a/ui/package.json b/ui/package.json
index 399ccee6..b0ba1e43 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -9,7 +9,7 @@
"preview": "vite preview"
},
"devDependencies": {
- "etherpad-webcomponents": "^0.0.4",
+ "etherpad-webcomponents": "^0.0.11",
"@types/node": "^25.6.0",
"esbuild": "0.28.0",
"typescript": "^6.0.2",
diff --git a/ui/src/js/ace.ts b/ui/src/js/ace.ts
index 32036c1c..2156207a 100644
--- a/ui/src/js/ace.ts
+++ b/ui/src/js/ace.ts
@@ -1,12 +1,14 @@
-// @ts-nocheck
-/**
- * This code is mostly from the old Etherpad. Please help us to comment this code.
- * This helps other people to understand this code better and helps them to improve it.
- * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
- */
-
/**
+ * Ace2Editor — Wrapper around the WebComponent-based AceEditor from etherpad-webcomponents.
+ *
+ * Replaces the old iframe-based editor (ace2_inner) with a direct contenteditable div.
+ * Maintains the same public API so collab_client, pad_editor, and plugins work unchanged.
+ *
+ * The key pattern: a shared `info` object holds `ace_*` prefixed methods that plugins
+ * and callWithAce callbacks use. This mirrors the original ace2_inner architecture.
+ *
* Copyright 2009 Google Inc.
+ * Copyright 2025 - Adapted for WebComponent-based editor.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -21,83 +23,21 @@
* limitations under the License.
*/
-// requires: top
-// requires: undefined
-
+import {AceEditor} from 'etherpad-webcomponents';
import {editorBus} from './core/EventBus';
-import {makeCSSManager} from './cssmanager';
import * as pluginUtils from './pluginfw/shared';
-const debugLog = (...args) => {};
-// The inner and outer iframe's locations are about:blank, so relative URLs are relative to that.
-// Firefox and Chrome seem to do what the developer intends if given a relative URL, but Safari
-// errors out unless given an absolute URL for a JavaScript-created element.
-const absUrl = (url) => new URL(url, window.location.href).href;
-
-const eventFired = async (obj, event, cleanups = [], predicate = () => true) => {
- if (typeof cleanups === 'function') {
- predicate = cleanups;
- cleanups = [];
- }
- await new Promise((resolve, reject) => {
- let cleanup;
- const successCb = () => {
- if (!predicate()) return;
- debugLog(`Ace2Editor.init() ${event} event on`, obj);
- cleanup();
- resolve();
- };
- const errorCb = (evt) => {
- // Upstream #7456: ignore error events from browser-extension scripts —
- // they are unrelated to Etherpad and should not block editor init.
- const src = evt?.target?.src || evt?.filename || '';
- if (/^(moz|chrome|safari)-extension:\/\//.test(src)) {
- debugLog('Ace2Editor.init() ignoring error from browser extension:', src);
- return;
- }
- const err = new Error(`Ace2Editor.init() error event while waiting for ${event} event`);
- debugLog(`${err} on object`, obj);
- cleanup();
- reject(err);
- };
- cleanup = () => {
- cleanup = () => {};
- obj.removeEventListener(event, successCb);
- obj.removeEventListener('error', errorCb);
- };
- cleanups.push(cleanup);
- obj.addEventListener(event, successCb);
- obj.addEventListener('error', errorCb);
- });
-};
-// Resolves when the frame's document is ready to be mutated. Browsers seem to be quirky about
-// iframe ready events so this function throws the kitchen sink at the problem. Maybe one day we'll
-// find a concise general solution.
-const frameReady = async (frame) => {
- // Can't do `const doc = frame.contentDocument;` because Firefox seems to asynchronously replace
- // the document object after the frame is first created for some reason. ¯\_(ツ)_/¯
- const doc = () => frame.contentDocument;
- const cleanups = [];
- try {
- await Promise.race([
- eventFired(frame, 'load', cleanups),
- eventFired(frame.contentWindow, 'load', cleanups),
- eventFired(doc(), 'load', cleanups),
- eventFired(doc(), 'DOMContentLoaded', cleanups),
- eventFired(doc(), 'readystatechange', cleanups, () => doc.readyState === 'complete'),
- ]);
- } finally {
- for (const cleanup of cleanups) cleanup();
- }
-};
-
-export const Ace2Editor = function () {
- let info = {editor: this};
+export const Ace2Editor = function (this: any) {
+ let editor: AceEditor | null = null;
let loaded = false;
- let actionsPendingInit = [];
+ // Shared info object — plugins and callWithAce callbacks access ace_* methods on this.
+ // This replicates the original editorInfo pattern from ace2_inner.
+ const info: Record = {editor: this};
- const pendingInit = (func) => function (...args) {
+ let actionsPendingInit: Array<() => void> = [];
+
+ const pendingInit = (func: (...args: any[]) => any) => function (this: any, ...args: any[]) {
const action = () => func.apply(this, args);
if (loaded) return action();
actionsPendingInit.push(action);
@@ -108,7 +48,120 @@ export const Ace2Editor = function () {
actionsPendingInit = [];
};
- // The following functions (prefixed by 'ace_') are exposed by editor, but
+ /**
+ * Populates the info object with ace_* methods that delegate to the AceEditor.
+ * Called once after editor.init() completes.
+ */
+ const populateInfo = () => {
+ const e = editor!;
+
+ // --- Core ---
+ info.ace_getRep = () => e.rep;
+ info.ace_getAuthor = () => (e as any).thisAuthor;
+ info.ace_focus = () => e.focus();
+ info.ace_setEditable = (val: boolean) => e.setEditable(val);
+ info.ace_getDocument = () => document;
+ info.ace_dispose = () => e.dispose();
+
+ // --- Text import/export ---
+ info.ace_importText = (text: string) => e.setText(text);
+ info.ace_importAText = (atext: any, apoolJsonObj: any) => e.setAttributedText(atext, apoolJsonObj);
+ info.ace_exportText = () => e.exportText();
+
+ // --- Properties ---
+ info.ace_setProperty = (key: string, value: any) => e.setProperty(key, value);
+
+ // --- Formatting ---
+ info.ace_toggleAttributeOnSelection = (name: string) => e.toggleAttribute(name);
+ info.ace_setAttributeOnSelection = (name: string, value: any) => {
+ (e as any).setAttributeOnSelection(name, value);
+ };
+ info.ace_getAttributeOnSelection = (name: string) => e.getAttribute(name);
+
+ // --- Lists ---
+ // Use private methods directly because callWithAce wraps in inCallStack already
+ info.ace_doInsertUnorderedList = () => (e as any).doInsertUnorderedList();
+ info.ace_doInsertOrderedList = () => (e as any).doInsertOrderedList();
+ // doIndentOutdent returns boolean (used by pad_editbar), so call private method
+ info.ace_doIndentOutdent = (isOut: boolean) => (e as any).doIndentOutdent(isOut);
+
+ // --- Undo/Redo ---
+ info.ace_doUndoRedo = (type: string) => {
+ if (type === 'undo') e.undo();
+ else if (type === 'redo') e.redo();
+ };
+
+ // --- Selection ---
+ info.ace_isCaret = () => e.isCaret();
+ info.ace_caretLine = () => e.getCaretLine();
+ info.ace_caretColumn = () => e.getCaretColumn();
+ info.ace_setSelection = (selection: any) => {
+ (e as any).performSelectionChange?.(selection);
+ };
+
+ // --- Document operations ---
+ info.ace_performDocumentApplyAttributesToCharRange = (start: number, end: number, attribs: any[]) => {
+ (e as any).performDocumentApplyAttributesToCharRange?.(start, end, attribs);
+ };
+ info.ace_performDocumentApplyAttributesToRange = (start: any, end: any, attribs: any[]) => {
+ if ((e as any).documentAttributeManager) {
+ (e as any).documentAttributeManager.setAttributesOnRange(start, end, attribs);
+ }
+ };
+ info.ace_setAttributeOnLine = (lineNum: number, attrName: string, attrValue: any) => {
+ if ((e as any).documentAttributeManager) {
+ (e as any).documentAttributeManager.setAttributeOnLine(lineNum, attrName, attrValue);
+ }
+ };
+ info.ace_removeAttributeOnLine = (lineNum: number, attrName: string) => {
+ if ((e as any).documentAttributeManager) {
+ (e as any).documentAttributeManager.removeAttributeOnLine(lineNum, attrName);
+ }
+ };
+
+ // --- Internal access (used by plugins) ---
+ info.ace_fastIncorp = (n: number) => (e as any).fastIncorp(n);
+ info.ace_inCallStack = (type: string, fn: () => any) => (e as any).inCallStack(type, fn);
+ info.ace_inCallStackIfNecessary = (type: string, fn: () => any) => (e as any).inCallStackIfNecessary(type, fn);
+ info.ace_getInInternationalComposition = () => e.getInInternationalComposition();
+ info.ace_replaceRange = (start: any, end: any, text: string) => e.replaceRange(start, end, text);
+ info.ace_execCommand = (cmd: string, ...args: any[]) => e.execCommand(cmd, ...args);
+
+ // --- Author ---
+ info.ace_setAuthorInfo = (author: string, i: any) => e.setAuthorInfo(author, i);
+ info.ace_getAuthorInfos = () => (e as any).authorInfos;
+
+ // --- Key handlers ---
+ info.ace_setOnKeyPress = (handler: any) => e.setOnKeyPress(handler);
+ info.ace_setOnKeyDown = (handler: any) => e.setOnKeyDown(handler);
+ info.ace_setNotifyDirty = (handler: any) => e.setNotifyDirty(handler);
+
+ // --- Collaboration ---
+ info.ace_setBaseText = (txt: string) => e.setBaseText(txt);
+ info.ace_setBaseAttributedText = (atxt: any, apoolJsonObj: any) => e.setBaseAttributedText(atxt, apoolJsonObj);
+ info.ace_applyChangesToBase = (c: string, optAuthor?: string, apoolJsonObj?: any) => e.applyChangesToBase(c, optAuthor, apoolJsonObj);
+ info.ace_prepareUserChangeset = () => e.prepareUserChangeset();
+ info.ace_applyPreparedChangesetToBase = () => e.applyPreparedChangesetToBase();
+ info.ace_setUserChangeNotificationCallback = (f: () => void) => e.setUserChangeNotificationCallback(f);
+
+ // --- callWithAce ---
+ info.ace_callWithAce = (fn: (aceInfo: any) => any, callStack?: string, normalize?: boolean) => {
+ let wrapper = () => fn(info);
+ if (normalize !== undefined) {
+ const inner = wrapper;
+ wrapper = () => {
+ info.ace_fastIncorp(9);
+ return inner();
+ };
+ }
+ if (callStack !== undefined) {
+ return info.ace_inCallStackIfNecessary(callStack, wrapper);
+ }
+ return wrapper();
+ };
+ };
+
+ // The following functions are exposed on Ace2Editor but
// execution is delayed until init is complete
const aceFunctionsPendingInit = [
'importText',
@@ -131,199 +184,101 @@ export const Ace2Editor = function () {
];
for (const fnName of aceFunctionsPendingInit) {
- // Note: info[`ace_${fnName}`] does not exist yet, so it can't be passed directly to
- // pendingInit(). A simple wrapper is used to defer the info[`ace_${fnName}`] lookup until
- // method invocation.
- this[fnName] = pendingInit(function (...args) {
- info[`ace_${fnName}`].apply(this, args);
+ this[fnName] = pendingInit(function (...args: any[]) {
+ info[`ace_${fnName}`].apply(info, args);
});
}
+ // Methods that return values immediately (or fallback if not loaded)
this.exportText = () => loaded ? info.ace_exportText() : '(awaiting init)\n';
-
- this.getInInternationalComposition =
- () => loaded ? info.ace_getInInternationalComposition() : null;
-
- // prepareUserChangeset:
- // Returns null if no new changes or ACE not ready. Otherwise, bundles up all user changes
- // to the latest base text into a Changeset, which is returned (as a string if encodeAsString).
- // If this method returns a truthy value, then applyPreparedChangesetToBase can be called at some
- // later point to consider these changes part of the base, after which prepareUserChangeset must
- // be called again before applyPreparedChangesetToBase. Multiple consecutive calls to
- // prepareUserChangeset will return an updated changeset that takes into account the latest user
- // changes, and modify the changeset to be applied by applyPreparedChangesetToBase accordingly.
+ this.getInInternationalComposition = () => loaded ? info.ace_getInInternationalComposition() : null;
this.prepareUserChangeset = () => loaded ? info.ace_prepareUserChangeset() : null;
- const addStyleTagsFor = (doc, files) => {
- for (const file of files) {
- const normalizedFile = file.startsWith('/static/plugins/') ||
- file.startsWith('/static/') ||
- file.startsWith('../') ||
- file.startsWith('./') ||
- file.startsWith('http://') ||
- file.startsWith('https://') ? file :
- file.startsWith('/') ? `/static/plugins${file}` : `/static/plugins/${file}`;
- const link = doc.createElement('link');
- link.rel = 'stylesheet';
- link.type = 'text/css';
- link.href = absUrl(encodeURI(normalizedFile));
- doc.head.appendChild(link);
- }
- };
-
this.destroy = pendingInit(() => {
info.ace_dispose();
- info.frame.parentNode.removeChild(info.frame);
- info = null; // prevent IE 6 closure memory leaks
+ const container = document.getElementById('editorcontainer');
+ if (container) container.innerHTML = '';
+ editor = null;
});
- this.init = async function (containerId, initialCode) {
- debugLog('Ace2Editor.init()');
- this.importText(initialCode);
+ this.init = async function (containerId: string, initialCode: string) {
+ if (initialCode) {
+ this.importText(initialCode);
+ }
- const includedCSS = [
- `../static/css/iframe_editor.css?v=${clientVars.randomVersionString}`,
- `../css/static/pad.css?v=${clientVars.randomVersionString}`,
- `../css/skin/${clientVars.skinName}/pad.css?v=${clientVars.randomVersionString}`,
- ];
- editorBus.emit('custom:ace:editor:css', {result: includedCSS, css: includedCSS});
+ const container = document.getElementById(containerId);
+ if (!container) throw new Error(`Container #${containerId} not found`);
+
+ const skinVariants = (window as any).clientVars?.skinVariants?.split(' ').filter((x: string) => x !== '') ?? [];
- const skinVariants = clientVars.skinVariants.split(' ').filter((x) => x !== '');
-
- const outerFrame = document.createElement('iframe');
- outerFrame.name = 'ace_outer';
- outerFrame.frameBorder = 0; // for IE
- outerFrame.title = 'Ether';
- // Some browsers do strange things unless the iframe has a src or srcdoc property:
- // - Firefox replaces the frame's contentWindow.document object with a different object after
- // the frame is created. This can be worked around by waiting for the window's load event
- // before continuing.
- // - Chrome never fires any events on the frame or document. Eventually the document's
- // readyState becomes 'complete' even though it never fires a readystatechange event.
- // - Safari behaves like Chrome.
- // srcdoc is avoided because Firefox's Content Security Policy engine does not properly handle
- // 'self' with nested srcdoc iframes: https://bugzilla.mozilla.org/show_bug.cgi?id=1721296
- outerFrame.src = '../static/empty.html';
- info.frame = outerFrame;
- document.getElementById(containerId).appendChild(outerFrame);
- const outerWindow = outerFrame.contentWindow;
-
- debugLog('Ace2Editor.init() waiting for outer frame');
- await frameReady(outerFrame);
- debugLog('Ace2Editor.init() outer frame ready');
-
- // Firefox might replace the outerWindow.document object after iframe creation so this variable
- // is assigned after the Window's load event.
- const outerDocument = outerWindow.document;
-
- // tag
- outerDocument.documentElement.classList.add('outer-editor', 'outerdoc', ...skinVariants);
-
- // tag
- addStyleTagsFor(outerDocument, includedCSS);
- const outerStyle = outerDocument.createElement('style');
- outerStyle.type = 'text/css';
- outerStyle.title = 'dynamicsyntax';
- outerDocument.head.appendChild(outerStyle);
-
- // tag
- outerDocument.body.id = 'outerdocbody';
- outerDocument.body.classList.add('outerdocbody', ...pluginUtils.clientPluginNames());
- const sideDiv = outerDocument.createElement('div');
+ // iframe_editor.css is loaded statically in pad.templ (no dynamic loading needed).
+
+ // Set up the editor container structure.
+ // Original structure: #editorcontainer > iframe(ace_outer) > body#outerdocbody > [sidediv, iframe(ace_inner) > body#innerdocbody]
+ // New structure: #editorcontainer > div#outerdocbody > [sidediv, div#innerdocbody]
+ container.innerHTML = '';
+
+ // Apply skin variants to html element. Do NOT add outer-editor/inner-editor classes here —
+ // those trigger "background-color: transparent !important" which was meant for iframes only.
+ document.documentElement.classList.add(...skinVariants);
+
+ // Create the outerdocbody container (replaces the outer iframe's body)
+ const outerBody = document.createElement('div');
+ outerBody.id = 'outerdocbody';
+ outerBody.classList.add('outerdocbody', ...pluginUtils.clientPluginNames());
+ container.appendChild(outerBody);
+
+ // Create sidediv for line numbers
+ const sideDiv = document.createElement('div');
sideDiv.id = 'sidediv';
sideDiv.classList.add('sidediv');
- outerDocument.body.appendChild(sideDiv);
- const sideDivInner = outerDocument.createElement('div');
+ const sideDivInner = document.createElement('div');
sideDivInner.id = 'sidedivinner';
sideDivInner.classList.add('sidedivinner');
sideDiv.appendChild(sideDivInner);
- const lineMetricsDiv = outerDocument.createElement('div');
- lineMetricsDiv.id = 'linemetricsdiv';
- lineMetricsDiv.appendChild(outerDocument.createTextNode('x'));
- outerDocument.body.appendChild(lineMetricsDiv);
-
- const innerFrame = outerDocument.createElement('iframe');
- innerFrame.name = 'ace_inner';
- innerFrame.title = 'pad';
- innerFrame.scrolling = 'no';
- innerFrame.frameBorder = 0;
- innerFrame.allowTransparency = true; // for IE
- // The iframe MUST have a src or srcdoc property to avoid browser quirks. See the comment above
- // outerFrame.srcdoc.
- innerFrame.src = 'empty.html';
- outerDocument.body.insertBefore(innerFrame, outerDocument.body.firstChild);
- const innerWindow = innerFrame.contentWindow;
-
- debugLog('Ace2Editor.init() waiting for inner frame');
- await frameReady(innerFrame);
- debugLog('Ace2Editor.init() inner frame ready');
-
- // Firefox might replace the innerWindow.document object after iframe creation so this variable
- // is assigned after the Window's load event.
- const innerDocument = innerWindow.document;
-
- // tag
- innerDocument.documentElement.classList.add('inner-editor', ...skinVariants);
-
- // tag
- addStyleTagsFor(innerDocument, includedCSS);
- //const requireKernel = innerDocument.createElement('script');
- //requireKernel.type = 'text/javascript';
- //requireKernel.src =
- // absUrl(`../static/js/require-kernel.js?v=${clientVars.randomVersionString}`);
- //innerDocument.head.appendChild(requireKernel);
- // Pre-fetch modules to improve load performance.
- /*for (const module of ['ace2_inner', 'ace2_common']) {
- const script = innerDocument.createElement('script');
- script.type = 'text/javascript';
- script.src = absUrl(`../javascripts/lib/ep_etherpad-lite/static/js/${module}.js` +
- `?callback=require.define&v=${clientVars.randomVersionString}`);
- innerDocument.head.appendChild(script);
- }*/
- const innerStyle = innerDocument.createElement('style');
- innerStyle.type = 'text/css';
- innerStyle.title = 'dynamicsyntax';
- innerDocument.head.appendChild(innerStyle);
- const headLines = [];
+ outerBody.appendChild(sideDiv);
+
+ // Create the contenteditable editor body (replaces the inner iframe's body)
+ const editorBody = document.createElement('div');
+ editorBody.id = 'innerdocbody';
+ editorBody.classList.add('innerdocbody');
+ editorBody.setAttribute('spellcheck', 'false');
+ // flex: 1 replaces the iframe rule "#outerdocbody iframe { flex: 1 auto; width: 100% }"
+ editorBody.style.flex = '1 auto';
+ editorBody.style.width = '100%';
+ // Remove browser focus outline (was invisible when inside an iframe)
+ editorBody.style.outline = 'none';
+ outerBody.appendChild(editorBody);
+
+ // Load plugin CSS
+ const includedCSS: string[] = [];
+ editorBus.emit('custom:ace:editor:css', {result: includedCSS, css: includedCSS});
+
+ // Load custom head content from plugins
+ const headLines: string[] = [];
editorBus.emit('custom:ace:init:innerdocbody:head', {iframeHTML: headLines});
- innerDocument.head.appendChild(
- innerDocument.createRange().createContextualFragment(headLines.join('\n')));
-
- // tag
- innerDocument.body.id = 'innerdocbody';
- innerDocument.body.classList.add('innerdocbody');
- innerDocument.body.setAttribute('spellcheck', 'false');
- innerDocument.body.appendChild(innerDocument.createTextNode('\u00A0')); //
-/*
- debugLog('Ace2Editor.init() waiting for require kernel load');
- await eventFired(requireKernel, 'load');
- debugLog('Ace2Editor.init() require kernel loaded');
- const require = innerWindow.require;
- require.setRootURI(absUrl('../javascripts/src'));
- require.setLibraryURI(absUrl('../javascripts/lib'));
- require.setGlobalKeyPath('require');
-*/
- // intentionally moved before requiring client_plugins to save a 307
- const [ace2Inner, clPlugins] = await Promise.all([
- import('./ace2_inner'),
- import('./pluginfw/client_plugins'),
- ]);
- innerWindow.Ace2Inner = ace2Inner;
- innerWindow.plugins = clPlugins;
-
- debugLog('Ace2Editor.init() waiting for plugins');
- /*await new Promise((resolve, reject) => innerWindow.plugins.ensure(
- (err) => err != null ? reject(err) : resolve()));*/
- debugLog('Ace2Editor.init() waiting for Ace2Inner.init()');
- await innerWindow.Ace2Inner.init(info, {
- inner: makeCSSManager(innerStyle.sheet),
- outer: makeCSSManager(outerStyle.sheet),
- parent: makeCSSManager(document.querySelector('style[title="dynamicsyntax"]').sheet),
- });
- debugLog('Ace2Editor.init() Ace2Inner.init() returned');
+ if (headLines.length > 0) {
+ document.head.appendChild(
+ document.createRange().createContextualFragment(headLines.join('\n')));
+ }
+
+ // Create and initialize the AceEditor
+ editor = new AceEditor(editorBody);
+ await editor.init();
+
+ // Populate the info object with ace_* methods
+ populateInfo();
+
+ // Mark container as initialized (removes visibility:hidden from CSS rule
+ // #editorcontainerbox #editorcontainer:not(.initialized))
+ container.classList.add('initialized');
+
+ // Emit the initialized event with info as editorInfo.
+ // Plugins set custom ace_* methods on this object, and callWithAce passes it to callbacks.
+ // This replaces the emit that was in ace2_inner.ts in the original code.
+ editorBus.emit('editor:ace:initialized', {editorInfo: info});
+
loaded = true;
doActionsPendingInit();
- debugLog('Ace2Editor.init() done');
};
};
-
diff --git a/ui/src/js/ace2_inner.ts b/ui/src/js/ace2_inner.ts
deleted file mode 100644
index 559f7c99..00000000
--- a/ui/src/js/ace2_inner.ts
+++ /dev/null
@@ -1,3617 +0,0 @@
-// @ts-nocheck
-import {Builder} from "./Builder";
-
-/**
- * Copyright 2009 Google Inc.
- * Copyright 2020 John McLear - The Etherpad Foundation.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS-IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-let documentAttributeManager;
-
-import AttributeMap from './AttributeMap';
-import {browserFlags as browser} from './browser_flags';
-import padutils from './pad_utils'
-import * as Ace2Common from './ace2_common';
-import {characterRangeFollow, checkRep, cloneAText, compose, deserializeOps, filterAttribNumbers, inverse, isIdentity, makeAText, makeAttribution, mapAttribNumbers, moveOpsToNewPool, mutateAttributionLines, mutateTextLines, oldLen, opsFromAText, pack, splitAttributionLines} from './Changeset'
-import {colorutils} from './colorutils';
-import {makeContentCollector} from './contentcollector';
-import {domline} from './domline';
-import {linestylefilter} from './linestylefilter';
-import {undoModule} from './undomodule';
-import {makeChangesetTracker} from './changesettracker';
-import AttributeManager from './AttributeManager';
-
-
-const isNodeText = Ace2Common.isNodeText;
-const getAssoc = Ace2Common.getAssoc;
-const setAssoc = Ace2Common.setAssoc;
-const noop = Ace2Common.noop;
-import {editorBus} from './core/EventBus';
-import SkipList from "./skiplist";
-import Scroll from './scroll'
-import AttribPool from './AttributePool'
-import {SmartOpAssembler} from "./SmartOpAssembler";
-import Op from "./Op";
-import {buildKeepRange, buildKeepToStartOfRange, buildRemoveRange} from './ChangesetUtils'
-import notifications from './notifications';
-
-function Ace2Inner(editorInfo, cssManagers) {
- const DEBUG = false;
-
- const THE_TAB = ' '; // 4
- const MAX_LIST_LEVEL = 16;
-
- const FORMATTING_STYLES = ['bold', 'italic', 'underline', 'strikethrough'];
- const SELECT_BUTTON_CLASS = 'selected';
-
- let thisAuthor = '';
-
- let disposed = false;
- const outerWin = document.getElementsByName("ace_outer")[0]
- const targetDoc = outerWin.contentWindow.document.getElementsByName("ace_inner")[0].contentWindow.document
- const targetBody = targetDoc.body
-
- const focus = () => {
- targetBody.focus();
- };
-
- const outerDoc = outerWin.contentWindow.document;
-
- const sideDiv = outerDoc.getElementById('sidediv');
- const lineMetricsDiv = outerDoc.getElementById('linemetricsdiv');
- const sideDivInner = outerDoc.getElementById('sidedivinner');
- const appendNewSideDivLine = () => {
- const lineDiv = outerDoc.createElement('div');
- sideDivInner.appendChild(lineDiv);
- const lineSpan = outerDoc.createElement('span');
- lineSpan.classList.add('line-number');
- lineSpan.appendChild(outerDoc.createTextNode(sideDivInner.children.length));
- lineDiv.appendChild(lineSpan);
- };
- appendNewSideDivLine();
-
- const scroll = new Scroll(outerWin);
-
- let outsideKeyDown = noop;
- let outsideKeyPress = (e) => true;
- let outsideNotifyDirty = noop;
-
- /**
- * Document representation.
- */
- const rep = {
- /**
- * The contents of the document. Each entry in this skip list is an object representing a
- * line (actually paragraph) of text. The line objects are created by createDomLineEntry().
- */
- lines: new SkipList(),
- /**
- * Start of the selection. Represented as an array of two non-negative numbers that point to the
- * first character of the selection: [zeroBasedLineNumber, zeroBasedColumnNumber]. Notes:
- * - There is an implicit newline character (not actually stored) at the end of every line.
- * Because of this, a selection that starts at the end of a line (column number equals the
- * number of characters in the line, not including the implicit newline) is not equivalent
- * to a selection that starts at the beginning of the next line. The same goes for the
- * selection end.
- * - If there are N lines, [N, 0] is valid for the start of the selection. [N, 0] indicates
- * that the selection starts just after the implicit newline at the end of the document's
- * last line (if the document has any lines). The same goes for the end of the selection.
- * - If a line starts with a line marker, a selection that starts at the beginning of the line
- * may start either immediately before (column = 0) or immediately after (column = 1) the
- * line marker, and the two are considered to be semantically equivalent. For safety, all
- * code should be written to accept either but only produce selections that start after the
- * line marker (the column number should be 1, not 0, when there is a line marker). The same
- * goes for the end of the selection.
- */
- selStart: null,
- /**
- * End of the selection. Represented as an array of two non-negative numbers that point to the
- * character just after the end of the selection: [zeroBasedLineNumber, zeroBasedColumnNumber].
- * See the above notes for selStart.
- */
- selEnd: null,
- /**
- * Whether the selection extends "backwards", so that the focus point (controlled with the arrow
- * keys) is at the beginning. This is not supported in IE, though native IE selections have that
- * behavior (which we try not to interfere with). Must be false if selection is collapsed!
- */
- selFocusAtStart: false,
- alltext: '',
- alines: [],
- apool: new AttribPool(),
- };
-
- // lines, alltext, alines, and DOM are set up in init()
- if (undoModule.enabled) {
- undoModule.apool = rep.apool;
- }
-
- let isEditable = true;
- let doesWrap = true;
- let hasLineNumbers = true;
- let isStyled = true;
-
- let console = (DEBUG && window.console);
-
- if (!window.console) {
- const names = [
- 'log',
- 'debug',
- 'info',
- 'warn',
- 'error',
- 'assert',
- 'dir',
- 'dirxml',
- 'group',
- 'groupEnd',
- 'time',
- 'timeEnd',
- 'count',
- 'trace',
- 'profile',
- 'profileEnd',
- ];
- console = {};
- for (const name of names) console[name] = noop;
- }
-
- const scheduler = window; // hack for opera required
-
- const performDocumentReplaceRange = (start, end, newText) => {
- if (start === undefined) start = rep.selStart;
- if (end === undefined) end = rep.selEnd;
-
- // start[0]: <--- start[1] --->CCCCCCCCCCC\n
- // CCCCCCCCCCCCCCCCCCCC\n
- // CCCC\n
- // end[0]: -------\n
- const builder = new Builder(rep.lines.totalWidth());
- buildKeepToStartOfRange(rep, builder, start);
- buildRemoveRange(rep, builder, start, end);
- builder.insert(newText, [
- ['author', thisAuthor],
- ], rep.apool);
- const cs = builder.toString();
-
- performDocumentApplyChangeset(cs);
- };
-
- const changesetTracker = makeChangesetTracker(scheduler, rep.apool, {
- withCallbacks: (operationName, f) => {
- inCallStackIfNecessary(operationName, () => {
- fastIncorp(1);
- f(
- {
- setDocumentAttributedText: (atext) => {
- setDocAText(atext);
- },
- applyChangesetToDocument: (changeset, preferInsertionAfterCaret) => {
- const oldEventType = currentCallStack.editEvent.eventType;
- currentCallStack.startNewEvent('nonundoable');
-
- performDocumentApplyChangeset(changeset, preferInsertionAfterCaret);
-
- currentCallStack.startNewEvent(oldEventType);
- },
- });
- });
- },
- });
-
- const authorInfos = {}; // presence of key determines if author is present in doc
- const getAuthorInfos = () => authorInfos;
- editorInfo.ace_getAuthorInfos = getAuthorInfos;
-
- const setAuthorStyle = (author, info) => {
- const authorSelector = getAuthorColorClassSelector(getAuthorClassName(author));
-
- if (!info) {
- cssManagers.inner.removeSelectorStyle(authorSelector);
- cssManagers.parent.removeSelectorStyle(authorSelector);
- } else if (info.bgcolor) {
- let bgcolor = info.bgcolor;
- if ((typeof info.fade) === 'number') {
- bgcolor = fadeColor(bgcolor, info.fade);
- }
- const textColor =
- colorutils.textColorFromBackgroundColor(bgcolor, window.clientVars.skinName);
- const styles = [
- cssManagers.inner.selectorStyle(authorSelector),
- cssManagers.parent.selectorStyle(authorSelector),
- ];
- for (const style of styles) {
- style.backgroundColor = bgcolor;
- style.color = textColor;
- style['padding-top'] = '3px';
- style['padding-bottom'] = '4px';
- }
- }
- };
-
- const setAuthorInfo = (author, info) => {
- if (!author) return; // author ID not set for some reason
- if ((typeof author) !== 'string') {
- // Potentially caused by: https://github.com/ether/etherpad-lite/issues/2802");
- throw new Error(`setAuthorInfo: author (${author}) is not a string`);
- }
- if (!info) {
- delete authorInfos[author];
- } else {
- authorInfos[author] = info;
- }
- setAuthorStyle(author, info);
- };
-
- const getAuthorClassName = (author) => `author-${author.replace(/[^a-y0-9]/g, (c) => {
- if (c === '.') return '-';
- return `z${c.charCodeAt(0)}z`;
- })}`;
-
- const className2Author = (className) => {
- if (className.substring(0, 7) === 'author-') {
- return className.substring(7).replace(/[a-y0-9]+|-|z.+?z/g, (cc) => {
- if (cc === '-') { return '.'; } else if (cc.charAt(0) === 'z') {
- return String.fromCharCode(Number(cc.slice(1, -1)));
- } else {
- return cc;
- }
- });
- }
- return null;
- };
-
- const getAuthorColorClassSelector = (oneClassName) => `.authorColors .${oneClassName}`;
-
- const fadeColor = (colorCSS, fadeFrac) => {
- let color = colorutils.css2triple(colorCSS);
- color = colorutils.blend(color, [1, 1, 1], fadeFrac);
- return colorutils.triple2css(color);
- };
-
- editorInfo.ace_getRep = () => rep;
-
- editorInfo.ace_getAuthor = () => thisAuthor;
-
- const _nonScrollableEditEvents = {
- applyChangesToBase: 1,
- };
-
- // EventBus: also allow plugins to register non-scrollable edit events via the bus
- // (No equivalent hook return needed — this is a one-time init registration.)
-
- const isScrollableEditEvent = (eventType) => !_nonScrollableEditEvents[eventType];
-
- let currentCallStack = null;
-
- const inCallStack = (type, action) => {
- if (disposed) return;
-
- const newEditEvent = (eventType) => ({
- eventType,
- backset: null,
- });
-
- const submitOldEvent = (evt) => {
- if (rep.selStart && rep.selEnd) {
- const selStartChar = rep.lines.offsetOfIndex(rep.selStart[0]) + rep.selStart[1];
- const selEndChar = rep.lines.offsetOfIndex(rep.selEnd[0]) + rep.selEnd[1];
- evt.selStart = selStartChar;
- evt.selEnd = selEndChar;
- evt.selFocusAtStart = rep.selFocusAtStart;
- }
- if (undoModule.enabled) {
- let undoWorked = false;
- try {
- if (isPadLoading(evt.eventType)) {
- undoModule.clearHistory();
- } else if (evt.eventType === 'nonundoable') {
- if (evt.changeset) {
- undoModule.reportExternalChange(evt.changeset);
- }
- } else {
- undoModule.reportEvent(evt);
- }
- undoWorked = true;
- } finally {
- if (!undoWorked) {
- undoModule.enabled = false; // for safety
- }
- }
- }
- };
-
- const startNewEvent = (eventType, dontSubmitOld) => {
- const oldEvent = currentCallStack.editEvent;
- if (!dontSubmitOld) {
- submitOldEvent(oldEvent);
- }
- currentCallStack.editEvent = newEditEvent(eventType);
- return oldEvent;
- };
-
- currentCallStack = {
- type,
- docTextChanged: false,
- selectionAffected: false,
- userChangedSelection: false,
- domClean: false,
- isUserChange: false,
- // is this a "user change" type of call-stack
- repChanged: false,
- editEvent: newEditEvent(type),
- startNewEvent,
- };
- let cleanExit = false;
- let result;
- try {
- result = action();
-
- // EventBus: emit editor:content:changed
- editorBus.emit('editor:content:changed', {text: rep.alltext});
-
- cleanExit = true;
- } finally {
- const cs = currentCallStack;
- if (cleanExit) {
- submitOldEvent(cs.editEvent);
- if (cs.domClean && cs.type !== 'setup') {
- if (cs.selectionAffected) {
- updateBrowserSelectionFromRep();
- }
- if ((cs.docTextChanged || cs.userChangedSelection) && isScrollableEditEvent(cs.type)) {
- scrollSelectionIntoView();
- }
- if (cs.docTextChanged && cs.type.indexOf('importText') < 0) {
- outsideNotifyDirty();
- }
- }
- } else if (currentCallStack.type === 'idleWorkTimer') {
- idleWorkTimer.atLeast(1000);
- }
- currentCallStack = null;
- }
- return result;
- };
- editorInfo.ace_inCallStack = inCallStack;
-
- const inCallStackIfNecessary = (type, action) => {
- if (!currentCallStack) {
- inCallStack(type, action);
- } else {
- action();
- }
- };
- editorInfo.ace_inCallStackIfNecessary = inCallStackIfNecessary;
-
- const dispose = () => {
- disposed = true;
- if (idleWorkTimer) idleWorkTimer.never();
- teardown();
- };
-
- const setWraps = (newVal) => {
- doesWrap = newVal;
- targetBody.classList.toggle('doesWrap', doesWrap);
- scheduler.setTimeout(() => {
- inCallStackIfNecessary('setWraps', () => {
- fastIncorp(7);
- recreateDOM();
- fixView();
- });
- }, 0);
- };
-
- const setStyled = (newVal) => {
- const oldVal = isStyled;
- isStyled = !!newVal;
-
- if (newVal !== oldVal) {
- if (!newVal) {
- // clear styles
- inCallStackIfNecessary('setStyled', () => {
- fastIncorp(12);
- const clearStyles = [];
- for (const k of Object.keys(STYLE_ATTRIBS)) {
- clearStyles.push([k, '']);
- }
- performDocumentApplyAttributesToCharRange(0, rep.alltext.length, clearStyles);
- });
- }
- }
- };
-
- const setTextFace = (face) => {
- targetBody.style.fontFamily = face;
- lineMetricsDiv.style.fontFamily = face;
- };
-
- const recreateDOM = () => {
- // precond: normalized
- recolorLinesInRange(0, rep.alltext.length);
- };
-
- const setEditable = (newVal) => {
- isEditable = newVal;
- targetBody.contentEditable = isEditable ? 'true' : 'false';
- targetBody.classList.toggle('static', !isEditable);
- };
-
- const enforceEditability = () => setEditable(isEditable);
-
- const importText = (text, undoable, dontProcess) => {
- let lines;
- if (dontProcess) {
- if (text.charAt(text.length - 1) !== '\n') {
- throw new Error('new raw text must end with newline');
- }
- if (/[\r\t\xa0]/.exec(text)) {
- throw new Error('new raw text must not contain CR, tab, or nbsp');
- }
- lines = text.substring(0, text.length - 1).split('\n');
- } else {
- lines = text.split('\n').map(textify);
- }
- let newText = '\n';
- if (lines.length > 0) {
- newText = `${lines.join('\n')}\n`;
- }
-
-
- inCallStackIfNecessary(`importText${undoable ? 'Undoable' : ''}`, () => {
- setDocText(newText);
- });
-
- if (dontProcess && rep.alltext !== text) {
- throw new Error('mismatch error setting raw text in importText');
- }
- };
-
- const importAText = (atext, apoolJsonObj, undoable) => {
- atext = cloneAText(atext);
- if (apoolJsonObj) {
- const wireApool = (new AttribPool()).fromJsonable(apoolJsonObj);
- atext.attribs = moveOpsToNewPool(atext.attribs, wireApool, rep.apool);
- }
- inCallStackIfNecessary(`importText${undoable ? 'Undoable' : ''}`, () => {
- setDocAText(atext);
- });
- };
-
- const setDocAText = (atext) => {
- if (atext.text === '') {
- /*
- * The server is fine with atext.text being an empty string, but the front
- * end is not, and crashes.
- *
- * It is not clear if this is a problem in the server or in the client
- * code, and this is a client-side hack fix. The underlying problem needs
- * to be investigated.
- *
- * See for reference:
- * - https://github.com/ether/etherpad-lite/issues/3861
- */
- atext.text = '\n';
- }
-
- fastIncorp(8);
-
- const oldLen = rep.lines.totalWidth();
- const numLines = rep.lines.length();
- const upToLastLine = rep.lines.offsetOfIndex(numLines - 1);
- const lastLineLength = rep.lines.atIndex(numLines - 1).text.length;
- const assem = new SmartOpAssembler();
- const o = new Op('-');
- o.chars = upToLastLine;
- o.lines = numLines - 1;
- assem.append(o);
- o.chars = lastLineLength;
- o.lines = 0;
- assem.append(o);
- for (const op of opsFromAText(atext)) assem.append(op);
- const newLen = oldLen + assem.getLengthChange();
- const changeset = checkRep(
- pack(oldLen, newLen, assem.toString(), atext.text.slice(0, -1)));
- performDocumentApplyChangeset(changeset);
-
- performSelectionChange(
- [0, rep.lines.atIndex(0).lineMarker], [0, rep.lines.atIndex(0).lineMarker]);
-
- idleWorkTimer.atMost(100);
-
- if (rep.alltext !== atext.text) {
- throw new Error('mismatch error setting raw text in setDocAText');
- }
- };
-
- const setDocText = (text) => {
- setDocAText(makeAText(text));
- };
-
- const getDocText = () => {
- const alltext = rep.alltext;
- let len = alltext.length;
- if (len > 0) len--; // final extra newline
- return alltext.substring(0, len);
- };
-
- const exportText = () => {
- if (currentCallStack && !currentCallStack.domClean) {
- inCallStackIfNecessary('exportText', () => {
- fastIncorp(2);
- });
- }
- return getDocText();
- };
-
- const editorChangedSize = () => fixView();
-
- const setOnKeyPress = (handler) => {
- outsideKeyPress = handler;
- };
-
- const setOnKeyDown = (handler) => {
- outsideKeyDown = handler;
- };
-
- const setNotifyDirty = (handler) => {
- outsideNotifyDirty = handler;
- };
-
- const CMDS = {
- clearauthorship: (prompt) => {
- if ((!(rep.selStart && rep.selEnd)) || isCaret()) {
- if (prompt) {
- prompt();
- } else {
- performDocumentApplyAttributesToCharRange(0, rep.alltext.length, [
- ['author', ''],
- ]);
- }
- } else {
- setAttributeOnSelection('author', '');
- }
- },
- };
-
- const execCommand = (cmd, ...args) => {
- cmd = cmd.toLowerCase();
- if (CMDS[cmd]) {
- inCallStackIfNecessary(cmd, () => {
- fastIncorp(9);
- CMDS[cmd](...args);
- });
- }
- };
-
- const replaceRange = (start, end, text) => {
- inCallStackIfNecessary('replaceRange', () => {
- fastIncorp(9);
- performDocumentReplaceRange(start, end, text);
- });
- };
-
- editorInfo.ace_callWithAce = (fn, callStack, normalize) => {
- let wrapper = () => fn(editorInfo);
-
- if (normalize !== undefined) {
- const wrapper1 = wrapper;
- wrapper = () => {
- editorInfo.ace_fastIncorp(9);
- wrapper1();
- };
- }
-
- if (callStack !== undefined) {
- return editorInfo.ace_inCallStack(callStack, wrapper);
- } else {
- return wrapper();
- }
- };
-
- /**
- * This methed exposes a setter for some ace properties
- * @param key the name of the parameter
- * @param value the value to set to
- */
- editorInfo.ace_setProperty = (key, value) => {
- // These properties are exposed
- const setters = {
- wraps: setWraps,
- showsauthorcolors: (val) => targetBody.classList.toggle('authorColors', !!val),
- showsuserselections: (val) => targetBody.classList.toggle('userSelections', !!val),
- showslinenumbers: (value) => {
- hasLineNumbers = !!value;
- sideDiv.parentNode.classList.toggle('line-numbers-hidden', !hasLineNumbers);
- fixView();
- },
- userauthor: (value) => {
- thisAuthor = String(value);
- documentAttributeManager.author = thisAuthor;
- },
- styled: setStyled,
- textface: setTextFace,
- rtlistrue: (value) => {
- targetBody.classList.toggle('rtl', value);
- targetBody.classList.toggle('ltr', !value);
- document.documentElement.dir = value ? 'rtl' : 'ltr';
- },
- };
-
- const setter = setters[key.toLowerCase()];
-
- // check if setter is present
- if (setter !== undefined) {
- setter(value);
- }
- };
-
- editorInfo.ace_setBaseText = (txt) => {
- changesetTracker.setBaseText(txt);
- };
- editorInfo.ace_setBaseAttributedText = (atxt, apoolJsonObj) => {
- changesetTracker.setBaseAttributedText(atxt, apoolJsonObj);
- };
- editorInfo.ace_applyChangesToBase = (c, optAuthor, apoolJsonObj) => {
- changesetTracker.applyChangesToBase(c, optAuthor, apoolJsonObj);
- };
- editorInfo.ace_prepareUserChangeset = () => changesetTracker.prepareUserChangeset();
- editorInfo.ace_applyPreparedChangesetToBase = () => {
- changesetTracker.applyPreparedChangesetToBase();
- };
- editorInfo.ace_setUserChangeNotificationCallback = (f) => {
- changesetTracker.setUserChangeNotificationCallback(f);
- };
- editorInfo.ace_setAuthorInfo = (author, info) => {
- setAuthorInfo(author, info);
- };
-
- editorInfo.ace_getDocument = () => document;
-
- const now = () => Date.now();
-
- const newTimeLimit = (ms) => {
- const startTime = now();
- let exceededAlready = false;
- let printedTrace = false;
- const isTimeUp = () => {
- if (exceededAlready) {
- if ((!printedTrace)) {
- printedTrace = true;
- }
- return true;
- }
- const elapsed = now() - startTime;
- if (elapsed > ms) {
- exceededAlready = true;
- return true;
- } else {
- return false;
- }
- };
-
- isTimeUp.elapsed = () => now() - startTime;
- return isTimeUp;
- };
-
-
- const makeIdleAction = (func) => {
- let scheduledTimeout = null;
- let scheduledTime = 0;
-
- const unschedule = () => {
- if (scheduledTimeout) {
- scheduler.clearTimeout(scheduledTimeout);
- scheduledTimeout = null;
- }
- };
-
- const reschedule = (time) => {
- unschedule();
- scheduledTime = time;
- let delay = time - now();
- if (delay < 0) delay = 0;
- scheduledTimeout = scheduler.setTimeout(callback, delay);
- };
-
- const callback = () => {
- scheduledTimeout = null;
- // func may reschedule the action
- func();
- };
-
- return {
- atMost: (ms) => {
- const latestTime = now() + ms;
- if ((!scheduledTimeout) || scheduledTime > latestTime) {
- reschedule(latestTime);
- }
- },
- // atLeast(ms) will schedule the action if not scheduled yet.
- // In other words, "infinity" is replaced by ms, even though
- // it is technically larger.
- atLeast: (ms) => {
- const earliestTime = now() + ms;
- if ((!scheduledTimeout) || scheduledTime < earliestTime) {
- reschedule(earliestTime);
- }
- },
- never: () => {
- unschedule();
- },
- };
- };
-
- const fastIncorp = (n) => {
- // normalize but don't do any lexing or anything
- incorporateUserChanges();
- };
- editorInfo.ace_fastIncorp = fastIncorp;
-
- const idleWorkTimer = makeIdleAction(() => {
- if (inInternationalComposition) {
- // don't do idle input incorporation during international input composition
- idleWorkTimer.atLeast(500);
- return;
- }
-
- inCallStackIfNecessary('idleWorkTimer', () => {
- const isTimeUp = newTimeLimit(250);
-
- let finishedImportantWork = false;
- let finishedWork = false;
-
- try {
- incorporateUserChanges();
-
- if (isTimeUp()) return;
-
- updateLineNumbers(); // update line numbers if any time left
- if (isTimeUp()) return;
- finishedImportantWork = true;
- finishedWork = true;
- } finally {
- if (finishedWork) {
- idleWorkTimer.atMost(1000);
- } else if (finishedImportantWork) {
- // if we've finished highlighting the view area,
- // more highlighting could be counter-productive,
- // e.g. if the user just opened a triple-quote and will soon close it.
- idleWorkTimer.atMost(500);
- } else {
- let timeToWait = Math.round(isTimeUp.elapsed() / 2);
- if (timeToWait < 100) timeToWait = 100;
- idleWorkTimer.atMost(timeToWait);
- }
- }
- });
- });
-
- let _nextId = 1;
-
- const uniqueId = (n) => {
- // not actually guaranteed to be unique, e.g. if user copy-pastes
- // nodes with ids
- const nid = n.id;
- if (nid) return nid;
- return (n.id = `magicdomid${_nextId++}`);
- };
-
-
- const recolorLinesInRange = (startChar, endChar) => {
- if (endChar <= startChar) return;
- if (startChar < 0 || startChar >= rep.lines.totalWidth()) return;
- let lineEntry = rep.lines.atOffset(startChar); // rounds down to line boundary
- let lineStart = rep.lines.offsetOfEntry(lineEntry);
- let lineIndex = rep.lines.indexOfEntry(lineEntry);
- let selectionNeedsResetting = false;
- let firstLine = null;
-
- // tokenFunc function; accesses current value of lineEntry and curDocChar,
- // also mutates curDocChar
- const tokenFunc = (tokenText, tokenClass) => {
- lineEntry.domInfo.appendSpan(tokenText, tokenClass);
- };
-
- while (lineEntry && lineStart < endChar) {
- const lineEnd = lineStart + lineEntry.width;
- lineEntry.domInfo.clearSpans();
- getSpansForLine(lineEntry, tokenFunc, lineStart);
- lineEntry.domInfo.finishUpdate();
-
- markNodeClean(lineEntry.lineNode);
-
- if (rep.selStart && rep.selStart[0] === lineIndex ||
- rep.selEnd && rep.selEnd[0] === lineIndex) {
- selectionNeedsResetting = true;
- }
-
- if (firstLine == null) firstLine = lineIndex;
- lineStart = lineEnd;
- lineEntry = rep.lines.next(lineEntry);
- lineIndex++;
- }
- if (selectionNeedsResetting) {
- currentCallStack.selectionAffected = true;
- }
- };
-
- // like getSpansForRange, but for a line, and the func takes (text,class)
- // instead of (width,class); excludes the trailing '\n' from
- // consideration by func
-
-
- const getSpansForLine = (lineEntry, textAndClassFunc, lineEntryOffsetHint) => {
- let lineEntryOffset = lineEntryOffsetHint;
- if ((typeof lineEntryOffset) !== 'number') {
- lineEntryOffset = rep.lines.offsetOfEntry(lineEntry);
- }
- const text = lineEntry.text;
- if (text.length === 0) {
- // allow getLineStyleFilter to set line-div styles
- const func = linestylefilter.getLineStyleFilter(
- 0, '', textAndClassFunc, rep.apool);
- func('', '');
- } else {
- let filteredFunc = linestylefilter.getFilterStack(text, textAndClassFunc, browser);
- const lineNum = rep.lines.indexOfEntry(lineEntry);
- const aline = rep.alines[lineNum];
- filteredFunc = linestylefilter.getLineStyleFilter(
- text.length, aline, filteredFunc, rep.apool);
- filteredFunc(text, '');
- }
- };
-
- let observedChanges;
-
- const clearObservedChanges = () => {
- observedChanges = {
- cleanNodesNearChanges: {},
- };
- };
- clearObservedChanges();
-
- const getCleanNodeByKey = (key) => {
- let n = targetDoc.getElementById(key);
- // copying and pasting can lead to duplicate ids
- while (n && isNodeDirty(n)) {
- n.id = '';
- n = targetDoc.getElementById(key);
- }
- return n;
- };
-
- const observeChangesAroundNode = (node) => {
- // Around this top-level DOM node, look for changes to the document
- // (from how it looks in our representation) and record them in a way
- // that can be used to "normalize" the document (apply the changes to our
- // representation, and put the DOM in a canonical form).
- let cleanNode;
- let hasAdjacentDirtyness;
- if (!isNodeDirty(node)) {
- cleanNode = node;
- const prevSib = cleanNode.previousSibling;
- const nextSib = cleanNode.nextSibling;
- hasAdjacentDirtyness = ((prevSib && isNodeDirty(prevSib)) ||
- (nextSib && isNodeDirty(nextSib)));
- } else {
- // node is dirty, look for clean node above
- let upNode = node.previousSibling;
- while (upNode && isNodeDirty(upNode)) {
- upNode = upNode.previousSibling;
- }
- if (upNode) {
- cleanNode = upNode;
- } else {
- let downNode = node.nextSibling;
- while (downNode && isNodeDirty(downNode)) {
- downNode = downNode.nextSibling;
- }
- if (downNode) {
- cleanNode = downNode;
- }
- }
- if (!cleanNode) {
- // Couldn't find any adjacent clean nodes!
- // Since top and bottom of doc is dirty, the dirty area will be detected.
- return;
- }
- hasAdjacentDirtyness = true;
- }
-
- if (hasAdjacentDirtyness) {
- // previous or next line is dirty
- observedChanges.cleanNodesNearChanges[`$${uniqueId(cleanNode)}`] = true;
- } else {
- // next and prev lines are clean (if they exist)
- const lineKey = uniqueId(cleanNode);
- const prevSib = cleanNode.previousSibling;
- const nextSib = cleanNode.nextSibling;
- const actualPrevKey = ((prevSib && uniqueId(prevSib)) || null);
- const actualNextKey = ((nextSib && uniqueId(nextSib)) || null);
- const repPrevEntry = rep.lines.prev(rep.lines.atKey(lineKey));
- const repNextEntry = rep.lines.next(rep.lines.atKey(lineKey));
- const repPrevKey = ((repPrevEntry && repPrevEntry.key) || null);
- const repNextKey = ((repNextEntry && repNextEntry.key) || null);
- if (actualPrevKey !== repPrevKey || actualNextKey !== repNextKey) {
- observedChanges.cleanNodesNearChanges[`$${uniqueId(cleanNode)}`] = true;
- }
- }
- };
-
- const observeChangesAroundSelection = () => {
- if (currentCallStack.observedSelection) return;
- currentCallStack.observedSelection = true;
-
- const selection = getSelection();
-
- if (selection) {
- const node1 = topLevel(selection.startPoint.node);
- const node2 = topLevel(selection.endPoint.node);
- if (node1) observeChangesAroundNode(node1);
- if (node2 && node1 !== node2) {
- observeChangesAroundNode(node2);
- }
- }
- };
-
- const observeSuspiciousNodes = () => {
- // inspired by Firefox bug #473255, where pasting formatted text
- // causes the cursor to jump away, making the new HTML never found.
- if (targetBody.getElementsByTagName) {
- const elts = targetBody.getElementsByTagName('style');
- for (const elt of elts) {
- const n = topLevel(elt);
- if (n && n.parentNode === targetBody) {
- observeChangesAroundNode(n);
- }
- }
- }
- };
-
- const incorporateUserChanges = () => {
- if (currentCallStack.domClean) return false;
-
- currentCallStack.isUserChange = true;
-
- if (DEBUG && window.DONT_INCORP || window.DEBUG_DONT_INCORP) return false;
-
- // returns true if dom changes were made
- if (!targetBody.firstChild) {
- targetBody.innerHTML = '
';
- }
-
- observeChangesAroundSelection();
- observeSuspiciousNodes();
- let dirtyRanges = getDirtyRanges();
- let dirtyRangesCheckOut = true;
- let j = 0;
- let a, b;
- let scrollToTheLeftNeeded = false;
-
- while (j < dirtyRanges.length) {
- a = dirtyRanges[j][0];
- b = dirtyRanges[j][1];
- if (!((a === 0 || getCleanNodeByKey(rep.lines.atIndex(a - 1).key)) &&
- (b === rep.lines.length() || getCleanNodeByKey(rep.lines.atIndex(b).key)))) {
- dirtyRangesCheckOut = false;
- break;
- }
- j++;
- }
- if (!dirtyRangesCheckOut) {
- for (const bodyNode of targetBody.childNodes) {
- if ((bodyNode.tagName) && ((!bodyNode.id) || (!rep.lines.containsKey(bodyNode.id)))) {
- observeChangesAroundNode(bodyNode);
- }
- }
- dirtyRanges = getDirtyRanges();
- }
-
- clearObservedChanges();
-
- const selection = getSelection();
-
- let selStart, selEnd; // each one, if truthy, has [line,char] needed to set selection
- let i = 0;
- const splicesToDo = [];
- let netNumLinesChangeSoFar = 0;
- const toDeleteAtEnd = [];
- const domInsertsNeeded = []; // each entry is [nodeToInsertAfter, [info1, info2, ...]]
- while (i < dirtyRanges.length) {
- const range = dirtyRanges[i];
- a = range[0];
- b = range[1];
- let firstDirtyNode = (((a === 0) && targetBody.firstChild) ||
- getCleanNodeByKey(rep.lines.atIndex(a - 1).key).nextSibling);
- firstDirtyNode = (firstDirtyNode && isNodeDirty(firstDirtyNode) && firstDirtyNode);
-
- let lastDirtyNode = (((b === rep.lines.length()) && targetBody.lastChild) ||
- getCleanNodeByKey(rep.lines.atIndex(b).key).previousSibling);
-
- lastDirtyNode = (lastDirtyNode && isNodeDirty(lastDirtyNode) && lastDirtyNode);
- if (firstDirtyNode && lastDirtyNode) {
- const cc = makeContentCollector(isStyled, browser, rep.apool, className2Author);
- cc.notifySelection(selection);
- const dirtyNodes = [];
- for (let n = firstDirtyNode; n &&
- !(n.previousSibling && n.previousSibling === lastDirtyNode);
- n = n.nextSibling) {
- cc.collectContent(n);
- dirtyNodes.push(n);
- }
- cc.notifyNextNode(lastDirtyNode.nextSibling);
- let lines = cc.getLines();
- if ((lines.length <= 1 || lines[lines.length - 1] !== '') && lastDirtyNode.nextSibling) {
- // dirty region doesn't currently end a line, even taking the following node
- // (or lack of node) into account, so include the following clean node.
- // It could be SPAN or a DIV; basically this is any case where the contentCollector
- // decides it isn't done.
- // Note that this clean node might need to be there for the next dirty range.
- b++;
- const cleanLine = lastDirtyNode.nextSibling;
- cc.collectContent(cleanLine);
- toDeleteAtEnd.push(cleanLine);
- cc.notifyNextNode(cleanLine.nextSibling);
- }
-
- const ccData = cc.finish();
- const ss = ccData.selStart;
- const se = ccData.selEnd;
- lines = ccData.lines;
- const lineAttribs = ccData.lineAttribs;
- const linesWrapped = ccData.linesWrapped;
-
- if (linesWrapped > 0) {
- // Chrome decides in its infinite wisdom that it's okay to put the browser's visisble
- // window in the middle of the span. An outcome of this is that the first chars of the
- // string are no longer visible to the user.. Yay chrome.. Move the browser's visible area
- // to the left hand side of the span. Firefox isn't quite so bad, but it's still pretty
- // quirky.
- scrollToTheLeftNeeded = true;
- }
-
- if (ss[0] >= 0) selStart = [ss[0] + a + netNumLinesChangeSoFar, ss[1]];
- if (se[0] >= 0) selEnd = [se[0] + a + netNumLinesChangeSoFar, se[1]];
-
- const entries = [];
- const nodeToAddAfter = lastDirtyNode;
- const lineNodeInfos = [];
- for (const lineString of lines) {
- const newEntry = createDomLineEntry(lineString);
- entries.push(newEntry);
- lineNodeInfos.push(newEntry.domInfo);
- }
- domInsertsNeeded.push([nodeToAddAfter, lineNodeInfos]);
- for (const n of dirtyNodes) toDeleteAtEnd.push(n);
- const spliceHints = {};
- if (selStart) spliceHints.selStart = selStart;
- if (selEnd) spliceHints.selEnd = selEnd;
- splicesToDo.push([a + netNumLinesChangeSoFar, b - a, entries, lineAttribs, spliceHints]);
- netNumLinesChangeSoFar += (lines.length - (b - a));
- } else if (b > a) {
- splicesToDo.push([a + netNumLinesChangeSoFar, b - a, [], []]);
- }
- i++;
- }
-
- const domChanges = (splicesToDo.length > 0);
-
- for (const splice of splicesToDo) doIncorpLineSplice(...splice);
- for (const ins of domInsertsNeeded) insertDomLines(...ins);
- for (const n of toDeleteAtEnd) n.remove();
-
- // needed to stop chrome from breaking the ui when long strings without spaces are pasted
- if (scrollToTheLeftNeeded) targetBody.scrollLeft = 0;
-
- // if the nodes that define the selection weren't encountered during
- // content collection, figure out where those nodes are now.
- if (selection && !selStart) {
- selStart = getLineAndCharForPoint(selection.startPoint);
- }
- if (selection && !selEnd) {
- selEnd = getLineAndCharForPoint(selection.endPoint);
- }
-
- // selection from content collection can, in various ways, extend past final
- // BR in firefox DOM, so cap the line
- const numLines = rep.lines.length();
- if (selStart && selStart[0] >= numLines) {
- selStart[0] = numLines - 1;
- selStart[1] = rep.lines.atIndex(selStart[0]).text.length;
- }
- if (selEnd && selEnd[0] >= numLines) {
- selEnd[0] = numLines - 1;
- selEnd[1] = rep.lines.atIndex(selEnd[0]).text.length;
- }
-
- // update rep if we have a new selection
- // NOTE: IE loses the selection when you click stuff in e.g. the
- // editbar, so removing the selection when it's lost is not a good
- // idea.
- if (selection) repSelectionChange(selStart, selEnd, selection && selection.focusAtStart);
- // update browser selection
- if (selection && (domChanges || isCaret())) {
- // if no DOM changes (not this case), want to treat range selection delicately,
- // e.g. in IE not lose which end of the selection is the focus/anchor;
- // on the other hand, we may have just noticed a press of PageUp/PageDown
- currentCallStack.selectionAffected = true;
- }
-
- currentCallStack.domClean = true;
-
- fixView();
-
- return domChanges;
- };
-
- const STYLE_ATTRIBS = {
- bold: true,
- italic: true,
- underline: true,
- strikethrough: true,
- list: true,
- };
-
- const isStyleAttribute = (aname) => !!STYLE_ATTRIBS[aname];
-
- const isDefaultLineAttribute =
- (aname) => AttributeManager.DEFAULT_LINE_ATTRIBUTES.indexOf(aname) !== -1;
-
- const insertDomLines = (nodeToAddAfter, infoStructs) => {
- let lastEntry;
- let lineStartOffset;
- for (const info of infoStructs) {
- const node = info.node;
- const key = uniqueId(node);
- let entry;
- if (lastEntry) {
- // optimization to avoid recalculation
- const next = rep.lines.next(lastEntry);
- if (next && next.key === key) {
- entry = next;
- lineStartOffset += lastEntry.width;
- }
- }
- if (!entry) {
- entry = rep.lines.atKey(key);
- lineStartOffset = rep.lines.offsetOfKey(key);
- }
- lastEntry = entry;
- getSpansForLine(entry, (tokenText, tokenClass) => {
- info.appendSpan(tokenText, tokenClass);
- }, lineStartOffset);
- info.prepareForAdd();
- entry.lineMarker = info.lineMarker;
- if (!nodeToAddAfter) {
- targetBody.insertBefore(node, targetBody.firstChild);
- } else {
- targetBody.insertBefore(node, nodeToAddAfter.nextSibling);
- }
- nodeToAddAfter = node;
- info.notifyAdded();
- markNodeClean(node);
- }
- };
-
- const isCaret = () => (rep.selStart && rep.selEnd &&
- rep.selStart[0] === rep.selEnd[0] && rep.selStart[1] === rep.selEnd[1]);
- editorInfo.ace_isCaret = isCaret;
-
- // prereq: isCaret()
- const caretLine = () => rep.selStart[0];
-
- editorInfo.ace_caretLine = caretLine;
-
- const caretColumn = () => rep.selStart[1];
-
- editorInfo.ace_caretColumn = caretColumn;
-
- const caretDocChar = () => rep.lines.offsetOfIndex(caretLine()) + caretColumn();
-
- editorInfo.ace_caretDocChar = caretDocChar;
-
- const handleReturnIndentation = () => {
- // on return, indent to level of previous line
- if (isCaret() && caretColumn() === 0 && caretLine() > 0) {
- const lineNum = caretLine();
- const thisLine = rep.lines.atIndex(lineNum);
- const prevLine = rep.lines.prev(thisLine);
- const prevLineText = prevLine.text;
- let theIndent = /^ *(?:)/.exec(prevLineText)[0];
- const shouldIndent = window.clientVars.indentationOnNewLine;
- if (shouldIndent && /[[(:{]\s*$/.exec(prevLineText)) {
- theIndent += THE_TAB;
- }
- const cs = new Builder(rep.lines.totalWidth()).keep(
- rep.lines.offsetOfIndex(lineNum), lineNum).insert(
- theIndent, [
- ['author', thisAuthor],
- ], rep.apool).toString();
- performDocumentApplyChangeset(cs);
- performSelectionChange([lineNum, theIndent.length], [lineNum, theIndent.length]);
- }
- };
-
- const getPointForLineAndChar = (lineAndChar) => {
- const line = lineAndChar[0];
- let charsLeft = lineAndChar[1];
- const lineEntry = rep.lines.atIndex(line);
- charsLeft -= lineEntry.lineMarker;
- if (charsLeft < 0) {
- charsLeft = 0;
- }
- const lineNode = lineEntry.lineNode;
- let n = lineNode;
- let after = false;
- if (charsLeft === 0) {
- return {
- node: lineNode,
- index: 0,
- maxIndex: 1,
- };
- }
- while (!(n === lineNode && after)) {
- if (after) {
- if (n.nextSibling) {
- n = n.nextSibling;
- after = false;
- } else { n = n.parentNode; }
- } else if (isNodeText(n)) {
- const len = n.nodeValue.length;
- if (charsLeft <= len) {
- return {
- node: n,
- index: charsLeft,
- maxIndex: len,
- };
- }
- charsLeft -= len;
- after = true;
- } else if (n.firstChild) { n = n.firstChild; } else { after = true; }
- }
- return {
- node: lineNode,
- index: 1,
- maxIndex: 1,
- };
- };
-
- const nodeText = (n) => n.textContent || n.nodeValue || '';
-
- const getLineAndCharForPoint = (point) => {
- // Turn DOM node selection into [line,char] selection.
- // This method has to work when the DOM is not pristine,
- // assuming the point is not in a dirty node.
- if (point.node === targetBody) {
- if (point.index === 0) {
- return [0, 0];
- } else {
- const N = rep.lines.length();
- const ln = rep.lines.atIndex(N - 1);
- return [N - 1, ln.text.length];
- }
- } else {
- let n = point.node;
- let col = 0;
- // if this part fails, it probably means the selection node
- // was dirty, and we didn't see it when collecting dirty nodes.
- if (isNodeText(n)) {
- col = point.index;
- } else if (point.index > 0) {
- col = nodeText(n).length;
- }
- let parNode, prevSib;
- while ((parNode = n.parentNode) !== targetBody) {
- if ((prevSib = n.previousSibling)) {
- n = prevSib;
- col += nodeText(n).length;
- } else {
- n = parNode;
- }
- }
- if (n.firstChild && isBlockElement(n.firstChild)) {
- col += 1; // lineMarker
- }
- const lineEntry = rep.lines.atKey(n.id);
- const lineNum = rep.lines.indexOfEntry(lineEntry);
- return [lineNum, col];
- }
- };
- editorInfo.ace_getLineAndCharForPoint = getLineAndCharForPoint;
-
- const createDomLineEntry = (lineString) => {
- const info = doCreateDomLine(lineString.length > 0);
- const newNode = info.node;
- return {
- key: uniqueId(newNode),
- text: lineString,
- lineNode: newNode,
- domInfo: info,
- lineMarker: 0,
- };
- };
-
- const performDocumentApplyChangeset = (changes, insertsAfterSelection) => {
- const domAndRepSplice = (startLine, deleteCount, newLineStrings) => {
- const keysToDelete = [];
- if (deleteCount > 0) {
- let entryToDelete = rep.lines.atIndex(startLine);
- for (let i = 0; i < deleteCount; i++) {
- keysToDelete.push(entryToDelete.key);
- entryToDelete = rep.lines.next(entryToDelete);
- }
- }
-
- const lineEntries = newLineStrings.map(createDomLineEntry);
-
- doRepLineSplice(startLine, deleteCount, lineEntries);
-
- let nodeToAddAfter;
- if (startLine > 0) {
- nodeToAddAfter = getCleanNodeByKey(rep.lines.atIndex(startLine - 1).key);
- } else { nodeToAddAfter = null; }
-
- insertDomLines(nodeToAddAfter, lineEntries.map((entry) => entry.domInfo));
-
- for (const k of keysToDelete) {
- const n = targetDoc.getElementById(k);
- n.parentNode.removeChild(n);
- }
-
- if (
- (rep.selStart &&
- rep.selStart[0] >= startLine &&
- rep.selStart[0] <= startLine + deleteCount) ||
- (rep.selEnd && rep.selEnd[0] >= startLine && rep.selEnd[0] <= startLine + deleteCount)) {
- currentCallStack.selectionAffected = true;
- }
- };
-
- doRepApplyChangeset(changes, insertsAfterSelection);
-
- let requiredSelectionSetting = null;
- if (rep.selStart && rep.selEnd) {
- const selStartChar = rep.lines.offsetOfIndex(rep.selStart[0]) + rep.selStart[1];
- const selEndChar = rep.lines.offsetOfIndex(rep.selEnd[0]) + rep.selEnd[1];
- const result =
- characterRangeFollow(changes, selStartChar, selEndChar, insertsAfterSelection);
- requiredSelectionSetting = [result[0], result[1], rep.selFocusAtStart];
- }
-
- const linesMutatee = {
- splice: (start, numRemoved, ...args) => {
- domAndRepSplice(start, numRemoved, args.map((s) => s.slice(0, -1)));
- },
- get: (i) => `${rep.lines.atIndex(i).text}\n`,
- length: () => rep.lines.length(),
- };
-
- mutateTextLines(changes, linesMutatee);
-
- if (requiredSelectionSetting) {
- performSelectionChange(
- lineAndColumnFromChar(requiredSelectionSetting[0]),
- lineAndColumnFromChar(requiredSelectionSetting[1]),
- requiredSelectionSetting[2]);
- }
- };
-
- const doRepApplyChangeset = (changes, insertsAfterSelection) => {
- checkRep(changes);
-
- if (oldLen(changes) !== rep.alltext.length) {
- const errMsg = `${oldLen(changes)}/${rep.alltext.length}`;
- throw new Error(`doRepApplyChangeset length mismatch: ${errMsg}`);
- }
-
- const editEvent = currentCallStack.editEvent;
- if (editEvent.eventType === 'nonundoable') {
- if (!editEvent.changeset) {
- editEvent.changeset = changes;
- } else {
- editEvent.changeset = compose(editEvent.changeset, changes, rep.apool);
- }
- } else {
- const inverseChangeset = inverse(changes, {
- get: (i) => `${rep.lines.atIndex(i).text}\n`,
- length: () => rep.lines.length(),
- }, rep.alines, rep.apool);
-
- if (!editEvent.backset) {
- editEvent.backset = inverseChangeset;
- } else {
- editEvent.backset = compose(inverseChangeset, editEvent.backset, rep.apool);
- }
- }
-
- mutateAttributionLines(changes, rep.alines, rep.apool);
-
- if (changesetTracker.isTracking()) {
- changesetTracker.composeUserChangeset(changes);
- }
- };
-
- /**
- * Converts the position of a char (index in String) into a [row, col] tuple
- */
- const lineAndColumnFromChar = (x) => {
- const lineEntry = rep.lines.atOffset(x);
- const lineStart = rep.lines.offsetOfEntry(lineEntry);
- const lineNum = rep.lines.indexOfEntry(lineEntry);
- return [lineNum, x - lineStart];
- };
-
- const performDocumentReplaceCharRange = (startChar, endChar, newText) => {
- if (startChar === endChar && newText.length === 0) {
- return;
- }
- // Requires that the replacement preserve the property that the
- // internal document text ends in a newline. Given this, we
- // rewrite the splice so that it doesn't touch the very last
- // char of the document.
- if (endChar === rep.alltext.length) {
- if (startChar === endChar) {
- // an insert at end
- startChar--;
- endChar--;
- newText = `\n${newText.substring(0, newText.length - 1)}`;
- } else if (newText.length === 0) {
- // a delete at end
- startChar--;
- endChar--;
- } else {
- // a replace at end
- endChar--;
- newText = newText.substring(0, newText.length - 1);
- }
- }
- performDocumentReplaceRange(
- lineAndColumnFromChar(startChar), lineAndColumnFromChar(endChar), newText);
- };
-
- const performDocumentApplyAttributesToCharRange = (start, end, attribs) => {
- end = Math.min(end, rep.alltext.length - 1);
- documentAttributeManager.setAttributesOnRange(
- lineAndColumnFromChar(start), lineAndColumnFromChar(end), attribs);
- };
-
- editorInfo.ace_performDocumentApplyAttributesToCharRange =
- performDocumentApplyAttributesToCharRange;
-
- const setAttributeOnSelection = (attributeName, attributeValue) => {
- if (!(rep.selStart && rep.selEnd)) return;
-
- documentAttributeManager.setAttributesOnRange(rep.selStart, rep.selEnd, [
- [attributeName, attributeValue],
- ]);
- };
- editorInfo.ace_setAttributeOnSelection = setAttributeOnSelection;
-
- const getAttributeOnSelection = (attributeName, prevChar) => {
- if (!(rep.selStart && rep.selEnd)) return;
- const isNotSelection = (rep.selStart[0] === rep.selEnd[0] && rep.selEnd[1] === rep.selStart[1]);
- if (isNotSelection) {
- if (prevChar) {
- // If it's not the start of the line
- if (rep.selStart[1] !== 0) {
- rep.selStart[1]--;
- }
- }
- }
-
- const withIt = new AttributeMap(rep.apool).set(attributeName, 'true').toString();
- const withItRegex = new RegExp(`${withIt.replace(/\*/g, '\\*')}(\\*|$)`);
- const hasIt = (attribs) => withItRegex.test(attribs);
-
- const rangeHasAttrib = (selStart, selEnd) => {
- // if range is collapsed -> no attribs in range
- if (selStart[1] === selEnd[1] && selStart[0] === selEnd[0]) return false;
-
- if (selStart[0] !== selEnd[0]) { // -> More than one line selected
- let hasAttrib = true;
-
- // from selStart to the end of the first line
- hasAttrib = hasAttrib &&
- rangeHasAttrib(selStart, [selStart[0], rep.lines.atIndex(selStart[0]).text.length]);
-
- // for all lines in between
- for (let n = selStart[0] + 1; n < selEnd[0]; n++) {
- hasAttrib = hasAttrib && rangeHasAttrib([n, 0], [n, rep.lines.atIndex(n).text.length]);
- }
-
- // for the last, potentially partial, line
- hasAttrib = hasAttrib && rangeHasAttrib([selEnd[0], 0], [selEnd[0], selEnd[1]]);
-
- return hasAttrib;
- }
-
- // Logic tells us we now have a range on a single line
-
- const lineNum = selStart[0];
- const start = selStart[1];
- const end = selEnd[1];
- let hasAttrib = true;
-
- let indexIntoLine = 0;
- for (const op of deserializeOps(rep.alines[lineNum])) {
- const opStartInLine = indexIntoLine;
- const opEndInLine = opStartInLine + op.chars;
- if (!hasIt(op.attribs)) {
- // does op overlap selection?
- if (!(opEndInLine <= start || opStartInLine >= end)) {
- // since it's overlapping but hasn't got the attrib -> range hasn't got it
- hasAttrib = false;
- break;
- }
- }
- indexIntoLine = opEndInLine;
- }
-
- return hasAttrib;
- };
- return rangeHasAttrib(rep.selStart, rep.selEnd);
- };
-
- editorInfo.ace_getAttributeOnSelection = getAttributeOnSelection;
-
- const toggleAttributeOnSelection = (attributeName) => {
- if (!(rep.selStart && rep.selEnd)) return;
-
- let selectionAllHasIt = true;
- const withIt = new AttributeMap(rep.apool).set(attributeName, 'true').toString();
- const withItRegex = new RegExp(`${withIt.replace(/\*/g, '\\*')}(\\*|$)`);
-
- const hasIt = (attribs) => withItRegex.test(attribs);
-
- const selStartLine = rep.selStart[0];
- const selEndLine = rep.selEnd[0];
- for (let n = selStartLine; n <= selEndLine; n++) {
- let indexIntoLine = 0;
- let selectionStartInLine = 0;
- if (documentAttributeManager.lineHasMarker(n)) {
- selectionStartInLine = 1; // ignore "*" used as line marker
- }
- let selectionEndInLine = rep.lines.atIndex(n).text.length; // exclude newline
- if (n === selStartLine) {
- selectionStartInLine = rep.selStart[1];
- }
- if (n === selEndLine) {
- selectionEndInLine = rep.selEnd[1];
- }
- for (const op of deserializeOps(rep.alines[n])) {
- const opStartInLine = indexIntoLine;
- const opEndInLine = opStartInLine + op.chars;
- if (!hasIt(op.attribs)) {
- // does op overlap selection?
- if (!(opEndInLine <= selectionStartInLine || opStartInLine >= selectionEndInLine)) {
- selectionAllHasIt = false;
- break;
- }
- }
- indexIntoLine = opEndInLine;
- }
- if (!selectionAllHasIt) {
- break;
- }
- }
-
-
- const attributeValue = selectionAllHasIt ? '' : 'true';
- documentAttributeManager.setAttributesOnRange(
- rep.selStart, rep.selEnd, [[attributeName, attributeValue]]);
- if (attribIsFormattingStyle(attributeName)) {
- updateStyleButtonState(attributeName, !selectionAllHasIt); // italic, bold, ...
- }
- };
- editorInfo.ace_toggleAttributeOnSelection = toggleAttributeOnSelection;
-
- const performDocumentReplaceSelection = (newText) => {
- if (!(rep.selStart && rep.selEnd)) return;
- performDocumentReplaceRange(rep.selStart, rep.selEnd, newText);
- };
-
- // Change the abstract representation of the document to have a different set of lines.
- // Must be called after rep.alltext is set.
- const doRepLineSplice = (startLine, deleteCount, newLineEntries) => {
- for (const entry of newLineEntries) entry.width = entry.text.length + 1;
-
- const startOldChar = rep.lines.offsetOfIndex(startLine);
- const endOldChar = rep.lines.offsetOfIndex(startLine + deleteCount);
-
- rep.lines.splice(startLine, deleteCount, newLineEntries);
- currentCallStack.docTextChanged = true;
- currentCallStack.repChanged = true;
- const newText = newLineEntries.map((e) => `${e.text}\n`).join('');
-
- rep.alltext = rep.alltext.substring(0, startOldChar) +
- newText + rep.alltext.substring(endOldChar, rep.alltext.length);
- };
-
- const doIncorpLineSplice = (startLine, deleteCount, newLineEntries, lineAttribs, hints) => {
- const startOldChar = rep.lines.offsetOfIndex(startLine);
- const endOldChar = rep.lines.offsetOfIndex(startLine + deleteCount);
-
- const oldRegionStart = rep.lines.offsetOfIndex(startLine);
-
- let selStartHintChar, selEndHintChar;
- if (hints && hints.selStart) {
- selStartHintChar =
- rep.lines.offsetOfIndex(hints.selStart[0]) + hints.selStart[1] - oldRegionStart;
- }
- if (hints && hints.selEnd) {
- selEndHintChar = rep.lines.offsetOfIndex(hints.selEnd[0]) + hints.selEnd[1] - oldRegionStart;
- }
-
- const newText = newLineEntries.map((e) => `${e.text}\n`).join('');
- const oldText = rep.alltext.substring(startOldChar, endOldChar);
- const oldAttribs = rep.alines.slice(startLine, startLine + deleteCount).join('');
- const newAttribs = `${lineAttribs.join('|1+1')}|1+1`; // not valid in a changeset
- const analysis =
- analyzeChange(oldText, newText, oldAttribs, newAttribs, selStartHintChar, selEndHintChar);
- const commonStart = analysis[0];
- let commonEnd = analysis[1];
- let shortOldText = oldText.substring(commonStart, oldText.length - commonEnd);
- let shortNewText = newText.substring(commonStart, newText.length - commonEnd);
- let spliceStart = startOldChar + commonStart;
- let spliceEnd = endOldChar - commonEnd;
- let shiftFinalNewlineToBeforeNewText = false;
-
- // adjust the splice to not involve the final newline of the document;
- // be very defensive
- if (shortOldText.charAt(shortOldText.length - 1) === '\n' &&
- shortNewText.charAt(shortNewText.length - 1) === '\n') {
- // replacing text that ends in newline with text that also ends in newline
- // (still, after analysis, somehow)
- shortOldText = shortOldText.slice(0, -1);
- shortNewText = shortNewText.slice(0, -1);
- spliceEnd--;
- commonEnd++;
- }
- if (shortOldText.length === 0 &&
- spliceStart === rep.alltext.length &&
- shortNewText.length > 0) {
- // inserting after final newline, bad
- spliceStart--;
- spliceEnd--;
- shortNewText = `\n${shortNewText.slice(0, -1)}`;
- shiftFinalNewlineToBeforeNewText = true;
- }
- if (spliceEnd === rep.alltext.length &&
- shortOldText.length > 0 &&
- shortNewText.length === 0) {
- // deletion at end of rep.alltext
- if (rep.alltext.charAt(spliceStart - 1) === '\n') {
- // (if not then what the heck? it will definitely lead
- // to a rep.alltext without a final newline)
- spliceStart--;
- spliceEnd--;
- }
- }
-
- if (!(shortOldText.length === 0 && shortNewText.length === 0)) {
- const oldDocText = rep.alltext;
- const oldLen = oldDocText.length;
-
- const spliceStartLine = rep.lines.indexOfOffset(spliceStart);
- const spliceStartLineStart = rep.lines.offsetOfIndex(spliceStartLine);
-
- const startBuilder = () => {
- const builder = new Builder(oldLen);
- builder.keep(spliceStartLineStart, spliceStartLine);
- builder.keep(spliceStart - spliceStartLineStart);
- return builder;
- };
-
- const eachAttribRun = (attribs, func /* (startInNewText, endInNewText, attribs)*/) => {
- let textIndex = 0;
- const newTextStart = commonStart;
- const newTextEnd = newText.length - commonEnd - (shiftFinalNewlineToBeforeNewText ? 1 : 0);
- for (const op of deserializeOps(attribs)) {
- const nextIndex = textIndex + op.chars;
- if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) {
- func(Math.max(newTextStart, textIndex), Math.min(newTextEnd, nextIndex), op.attribs);
- }
- textIndex = nextIndex;
- }
- };
-
- const justApplyStyles = (shortNewText === shortOldText);
- let theChangeset;
-
- if (justApplyStyles) {
- // create changeset that clears the incorporated styles on
- // the existing text. we compose this with the
- // changeset the applies the styles found in the DOM.
- // This allows us to incorporate, e.g., Safari's native "unbold".
- const incorpedAttribClearer = cachedStrFunc(
- (oldAtts) => mapAttribNumbers(oldAtts, (n) => {
- const k = rep.apool.getAttribKey(n);
- if (isStyleAttribute(k)) {
- return rep.apool.putAttrib([k, '']);
- }
- return false;
- }));
-
- const builder1 = startBuilder();
- if (shiftFinalNewlineToBeforeNewText) {
- builder1.keep(1, 1);
- }
- eachAttribRun(oldAttribs, (start, end, attribs) => {
- builder1.keepText(newText.substring(start, end), incorpedAttribClearer(attribs));
- });
- const clearer = builder1.toString();
-
- const builder2 = startBuilder();
- if (shiftFinalNewlineToBeforeNewText) {
- builder2.keep(1, 1);
- }
- eachAttribRun(newAttribs, (start, end, attribs) => {
- builder2.keepText(newText.substring(start, end), attribs);
- });
- const styler = builder2.toString();
-
- theChangeset = compose(clearer, styler, rep.apool);
- } else {
- const builder = startBuilder();
-
- const spliceEndLine = rep.lines.indexOfOffset(spliceEnd);
- const spliceEndLineStart = rep.lines.offsetOfIndex(spliceEndLine);
- if (spliceEndLineStart > spliceStart) {
- builder.remove(spliceEndLineStart - spliceStart, spliceEndLine - spliceStartLine);
- builder.remove(spliceEnd - spliceEndLineStart);
- } else {
- builder.remove(spliceEnd - spliceStart);
- }
-
- let isNewTextMultiauthor = false;
- const authorizer = cachedStrFunc((oldAtts) => {
- const attribs = AttributeMap.fromString(oldAtts, rep.apool);
- if (!isNewTextMultiauthor || !attribs.has('author')) attribs.set('author', thisAuthor);
- return attribs.toString();
- });
-
- let foundDomAuthor = '';
- eachAttribRun(newAttribs, (start, end, attribs) => {
- const a = AttributeMap.fromString(attribs, rep.apool).get('author');
- if (a && a !== foundDomAuthor) {
- if (!foundDomAuthor) {
- foundDomAuthor = a;
- } else {
- isNewTextMultiauthor = true; // multiple authors in DOM!
- }
- }
- });
-
- if (shiftFinalNewlineToBeforeNewText) {
- builder.insert('\n', authorizer(''));
- }
-
- eachAttribRun(newAttribs, (start, end, attribs) => {
- builder.insert(newText.substring(start, end), authorizer(attribs));
- });
- theChangeset = builder.toString();
- }
-
- doRepApplyChangeset(theChangeset);
- }
-
- // do this no matter what, because we need to get the right
- // line keys into the rep.
- doRepLineSplice(startLine, deleteCount, newLineEntries);
- };
-
- const cachedStrFunc = (func) => {
- const cache = {};
- return (s) => {
- if (!cache[s]) {
- cache[s] = func(s);
- }
- return cache[s];
- };
- };
-
- const analyzeChange = (
- oldText, newText, oldAttribs, newAttribs, optSelStartHint, optSelEndHint) => {
- // we need to take into account both the styles attributes & attributes defined by
- // the plugins, so basically we can ignore only the default line attribs used by
- // Etherpad
- const incorpedAttribFilter = (anum) => !isDefaultLineAttribute(rep.apool.getAttribKey(anum));
-
- const attribRuns = (attribs) => {
- const lengs = [];
- const atts = [];
- for (const op of deserializeOps(attribs)) {
- lengs.push(op.chars);
- atts.push(op.attribs);
- }
- return [lengs, atts];
- };
-
- const attribIterator = (runs, backward) => {
- const lengs = runs[0];
- const atts = runs[1];
- let i = (backward ? lengs.length - 1 : 0);
- let j = 0;
- const next = () => {
- while (j >= lengs[i]) {
- if (backward) i--;
- else i++;
- j = 0;
- }
- const a = atts[i];
- j++;
- return a;
- };
- return next;
- };
-
- const oldLen = oldText.length;
- const newLen = newText.length;
- const minLen = Math.min(oldLen, newLen);
-
- const oldARuns = attribRuns(filterAttribNumbers(oldAttribs, incorpedAttribFilter));
- const newARuns = attribRuns(filterAttribNumbers(newAttribs, incorpedAttribFilter));
-
- let commonStart = 0;
- const oldStartIter = attribIterator(oldARuns, false);
- const newStartIter = attribIterator(newARuns, false);
- while (commonStart < minLen) {
- if (oldText.charAt(commonStart) === newText.charAt(commonStart) &&
- oldStartIter() === newStartIter()) {
- commonStart++;
- } else { break; }
- }
-
- let commonEnd = 0;
- const oldEndIter = attribIterator(oldARuns, true);
- const newEndIter = attribIterator(newARuns, true);
- while (commonEnd < minLen) {
- if (commonEnd === 0) {
- // assume newline in common
- oldEndIter();
- newEndIter();
- commonEnd++;
- } else if (
- oldText.charAt(oldLen - 1 - commonEnd) === newText.charAt(newLen - 1 - commonEnd) &&
- oldEndIter() === newEndIter()) {
- commonEnd++;
- } else { break; }
- }
-
- let hintedCommonEnd = -1;
- if ((typeof optSelEndHint) === 'number') {
- hintedCommonEnd = newLen - optSelEndHint;
- }
-
-
- if (commonStart + commonEnd > oldLen) {
- // ambiguous insertion
- const minCommonEnd = oldLen - commonStart;
- const maxCommonEnd = commonEnd;
- if (hintedCommonEnd >= minCommonEnd && hintedCommonEnd <= maxCommonEnd) {
- commonEnd = hintedCommonEnd;
- } else {
- commonEnd = minCommonEnd;
- }
- commonStart = oldLen - commonEnd;
- }
- if (commonStart + commonEnd > newLen) {
- // ambiguous deletion
- const minCommonEnd = newLen - commonStart;
- const maxCommonEnd = commonEnd;
- if (hintedCommonEnd >= minCommonEnd && hintedCommonEnd <= maxCommonEnd) {
- commonEnd = hintedCommonEnd;
- } else {
- commonEnd = minCommonEnd;
- }
- commonStart = newLen - commonEnd;
- }
-
- return [commonStart, commonEnd];
- };
-
- const equalLineAndChars = (a, b) => {
- if (!a) return !b;
- if (!b) return !a;
- return (a[0] === b[0] && a[1] === b[1]);
- };
-
- const performSelectionChange = (selectStart, selectEnd, focusAtStart) => {
- if (repSelectionChange(selectStart, selectEnd, focusAtStart)) {
- currentCallStack.selectionAffected = true;
- }
- };
- editorInfo.ace_performSelectionChange = performSelectionChange;
-
- // Change the abstract representation of the document to have a different selection.
- // Should not rely on the line representation. Should not affect the DOM.
-
-
- const repSelectionChange = (selectStart, selectEnd, focusAtStart) => {
- focusAtStart = !!focusAtStart;
-
- const newSelFocusAtStart = (focusAtStart && ((!selectStart) ||
- (!selectEnd) ||
- (selectStart[0] !== selectEnd[0]) ||
- (selectStart[1] !== selectEnd[1])));
-
- if ((!equalLineAndChars(rep.selStart, selectStart)) ||
- (!equalLineAndChars(rep.selEnd, selectEnd)) ||
- (rep.selFocusAtStart !== newSelFocusAtStart)) {
- rep.selStart = selectStart;
- rep.selEnd = selectEnd;
- rep.selFocusAtStart = newSelFocusAtStart;
- currentCallStack.repChanged = true;
-
- // select the formatting buttons when there is the style applied on selection
- selectFormattingButtonIfLineHasStyleApplied(rep);
-
- // EventBus: emit editor:selection:changed
- if (rep.selStart && rep.selEnd) {
- editorBus.emit('editor:selection:changed', {
- start: [rep.selStart[0], rep.selStart[1]] as [number, number],
- end: [rep.selEnd[0], rep.selEnd[1]] as [number, number],
- });
- }
-
- // we scroll when user places the caret at the last line of the pad
- // when this settings is enabled
- const docTextChanged = currentCallStack.docTextChanged;
- if (!docTextChanged) {
- const isScrollableEvent = !isPadLoading(currentCallStack.type) &&
- isScrollableEditEvent(currentCallStack.type);
- const innerHeight = getInnerHeight();
- scroll.scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary(
- rep, isScrollableEvent, innerHeight * 2);
- }
-
- return true;
- }
- return false;
- };
-
- const isPadLoading = (t) => t === 'setup' || t === 'setBaseText' || t === 'importText';
-
- const updateStyleButtonState = (attribName, hasStyleOnRepSelection) => {
- const formattingButton = document.querySelector(`[data-key="${attribName}"] a`);
- formattingButton?.classList.toggle(SELECT_BUTTON_CLASS, hasStyleOnRepSelection);
- };
-
- const attribIsFormattingStyle = (attribName) => FORMATTING_STYLES.indexOf(attribName) !== -1;
-
- const selectFormattingButtonIfLineHasStyleApplied = (rep) => {
- for (const style of FORMATTING_STYLES) {
- const hasStyleOnRepSelection =
- documentAttributeManager.hasAttributeOnSelectionOrCaretPosition(style);
- updateStyleButtonState(style, hasStyleOnRepSelection);
- }
- };
-
- const doCreateDomLine =
- (nonEmpty) => domline.createDomLine(nonEmpty, doesWrap, browser, document);
-
- const textify =
- (str) => str.replace(/[\n\r ]/g, ' ').replace(/\xa0/g, ' ').replace(/\t/g, ' ');
-
- const _blockElems = {
- div: 1,
- p: 1,
- pre: 1,
- li: 1,
- ol: 1,
- ul: 1,
- };
-
- // EventBus: allow plugins to register block elements via the bus
- const busBlockResult: string[] = [];
- editorBus.emit('editor:register:block:elements', {result: busBlockResult});
- for (const element of busBlockResult) _blockElems[element] = 1;
-
- const isBlockElement = (n) => !!_blockElems[(n.tagName || '').toLowerCase()];
- editorInfo.ace_isBlockElement = isBlockElement;
-
- const getDirtyRanges = () => {
- // based on observedChanges, return a list of ranges of original lines
- // that need to be removed or replaced with new user content to incorporate
- // the user's changes into the line representation. ranges may be zero-length,
- // indicating inserted content. for example, [0,0] means content was inserted
- // at the top of the document, while [3,4] means line 3 was deleted, modified,
- // or replaced with one or more new lines of content. ranges do not touch.
-
- const cleanNodeForIndexCache = {};
- const N = rep.lines.length(); // old number of lines
-
-
- const cleanNodeForIndex = (i) => {
- // if line (i) in the un-updated line representation maps to a clean node
- // in the document, return that node.
- // if (i) is out of bounds, return true. else return false.
- if (cleanNodeForIndexCache[i] === undefined) {
- let result;
- if (i < 0 || i >= N) {
- result = true; // truthy, but no actual node
- } else {
- const key = rep.lines.atIndex(i).key;
- result = (getCleanNodeByKey(key) || false);
- }
- cleanNodeForIndexCache[i] = result;
- }
- return cleanNodeForIndexCache[i];
- };
- const isConsecutiveCache = {};
-
- const isConsecutive = (i) => {
- if (isConsecutiveCache[i] === undefined) {
- isConsecutiveCache[i] = (() => {
- // returns whether line (i) and line (i-1), assumed to be map to clean DOM nodes,
- // or document boundaries, are consecutive in the changed DOM
- const a = cleanNodeForIndex(i - 1);
- const b = cleanNodeForIndex(i);
- if ((!a) || (!b)) return false; // violates precondition
- if ((a === true) && (b === true)) return !targetBody.firstChild;
- if ((a === true) && b.previousSibling) return false;
- if ((b === true) && a.nextSibling) return false;
- if ((a === true) || (b === true)) return true;
- return a.nextSibling === b;
- })();
- }
- return isConsecutiveCache[i];
- };
-
- // returns whether line (i) in the un-updated representation maps to a clean node,
- // or is outside the bounds of the document
- const isClean = (i) => !!cleanNodeForIndex(i);
-
- // list of pairs, each representing a range of lines that is clean and consecutive
- // in the changed DOM. lines (-1) and (N) are always clean, but may or may not
- // be consecutive with lines in the document. pairs are in sorted order.
- const cleanRanges = [
- [-1, N + 1],
- ];
-
- // returns index of cleanRange containing i, or -1 if none
- const rangeForLine = (i) => {
- for (const [idx, r] of cleanRanges.entries()) {
- if (i < r[0]) return -1;
- if (i < r[1]) return idx;
- }
- return -1;
- };
-
- const removeLineFromRange = (rng, line) => {
- // rng is index into cleanRanges, line is line number
- // precond: line is in rng
- const a = cleanRanges[rng][0];
- const b = cleanRanges[rng][1];
- if ((a + 1) === b) cleanRanges.splice(rng, 1);
- else if (line === a) cleanRanges[rng][0]++;
- else if (line === (b - 1)) cleanRanges[rng][1]--;
- else cleanRanges.splice(rng, 1, [a, line], [line + 1, b]);
- };
-
- const splitRange = (rng, pt) => {
- // precond: pt splits cleanRanges[rng] into two non-empty ranges
- const a = cleanRanges[rng][0];
- const b = cleanRanges[rng][1];
- cleanRanges.splice(rng, 1, [a, pt], [pt, b]);
- };
-
- const correctedLines = {};
-
- const correctlyAssignLine = (line) => {
- if (correctedLines[line]) return true;
- correctedLines[line] = true;
- // "line" is an index of a line in the un-updated rep.
- // returns whether line was already correctly assigned (i.e. correctly
- // clean or dirty, according to cleanRanges, and if clean, correctly
- // attached or not attached (i.e. in the same range as) the prev and next lines).
- const rng = rangeForLine(line);
- const lineClean = isClean(line);
- if (rng < 0) {
- if (lineClean) {
- // somehow lost clean line
- }
- return true;
- }
- if (!lineClean) {
- // a clean-range includes this dirty line, fix it
- removeLineFromRange(rng, line);
- return false;
- } else {
- // line is clean, but could be wrongly connected to a clean line
- // above or below
- const a = cleanRanges[rng][0];
- const b = cleanRanges[rng][1];
- let didSomething = false;
- // we'll leave non-clean adjacent nodes in the clean range for the caller to
- // detect and deal with. we deal with whether the range should be split
- // just above or just below this line.
- if (a < line && isClean(line - 1) && !isConsecutive(line)) {
- splitRange(rng, line);
- didSomething = true;
- }
- if (b > (line + 1) && isClean(line + 1) && !isConsecutive(line + 1)) {
- splitRange(rng, line + 1);
- didSomething = true;
- }
- return !didSomething;
- }
- };
-
- const detectChangesAroundLine = (line, reqInARow) => {
- // make sure cleanRanges is correct about line number "line" and the surrounding
- // lines; only stops checking at end of document or after no changes need
- // making for several consecutive lines. note that iteration is over old lines,
- // so this operation takes time proportional to the number of old lines
- // that are changed or missing, not the number of new lines inserted.
- let correctInARow = 0;
- let currentIndex = line;
- while (correctInARow < reqInARow && currentIndex >= 0) {
- if (correctlyAssignLine(currentIndex)) {
- correctInARow++;
- } else { correctInARow = 0; }
- currentIndex--;
- }
- correctInARow = 0;
- currentIndex = line;
- while (correctInARow < reqInARow && currentIndex < N) {
- if (correctlyAssignLine(currentIndex)) {
- correctInARow++;
- } else { correctInARow = 0; }
- currentIndex++;
- }
- };
-
- if (N === 0) {
- if (!isConsecutive(0)) {
- splitRange(0, 0);
- }
- } else {
- detectChangesAroundLine(0, 1);
- detectChangesAroundLine(N - 1, 1);
-
- for (const k of Object.keys(observedChanges.cleanNodesNearChanges)) {
- const key = k.substring(1);
- if (rep.lines.containsKey(key)) {
- const line = rep.lines.indexOfKey(key);
- detectChangesAroundLine(line, 2);
- }
- }
- }
-
- const dirtyRanges = [];
- for (let r = 0; r < cleanRanges.length - 1; r++) {
- dirtyRanges.push([cleanRanges[r][1], cleanRanges[r + 1][0]]);
- }
-
- return dirtyRanges;
- };
-
- const markNodeClean = (n) => {
- // clean nodes have knownHTML that matches their innerHTML
- setAssoc(n, 'dirtiness', {nodeId: uniqueId(n), knownHTML: n.innerHTML});
- };
-
- const isNodeDirty = (n) => {
- if (n.parentNode !== targetBody) return true;
- const data = getAssoc(n, 'dirtiness');
- if (!data) return true;
- if (n.id !== data.nodeId) return true;
- if (n.innerHTML !== data.knownHTML) return true;
- return false;
- };
-
- const handleClick = (evt) => {
- inCallStackIfNecessary('handleClick', () => {
- idleWorkTimer.atMost(200);
- });
-
- const isLink = (n) => (n.tagName || '').toLowerCase() === 'a' && n.href;
-
- // only want to catch left-click
- if ((evt.button !== 2) && (evt.button !== 3)) {
- // find A tag with HREF
- let n = evt.target;
- while (n && n.parentNode && !isLink(n)) {
- n = n.parentNode;
- }
- if (n && isLink(n)) {
- try {
- window.open(n.href, '_blank', 'noopener,noreferrer');
- if (evt.ctrlKey) window.focus();
- } catch (e) {
- // absorb "user canceled" error in IE for certain prompts
- }
- evt.preventDefault();
- }
- }
-
- hideEditBarDropdowns();
- };
-
- const hideEditBarDropdowns = () => {
- window.padeditbar.toggleDropDown('none');
- };
-
- const renumberList = (lineNum) => {
- // 1-check we are in a list
- let type = getLineListType(lineNum);
- if (!type) {
- return null;
- }
- type = /([a-z]+)[0-9]+/.exec(type);
- if (type[1] === 'indent') {
- return null;
- }
-
- // 2-find the first line of the list
- while (lineNum - 1 >= 0 && (type = getLineListType(lineNum - 1))) {
- type = /([a-z]+)[0-9]+/.exec(type);
- if (type[1] === 'indent') break;
- lineNum--;
- }
-
- // 3-renumber every list item of the same level from the beginning, level 1
- // IMPORTANT: never skip a level because there imbrication may be arbitrary
- const builder = new Builder(rep.lines.totalWidth());
- let loc = [0, 0];
- const applyNumberList = (line, level) => {
- // init
- let position = 1;
- let curLevel = level;
- let listType;
- let prevType = '';
- // loop over the lines
- while ((listType = getLineListType(line))) {
- // apply new num
- listType = /([a-z]+)([0-9]+)/.exec(listType);
- curLevel = Number(listType[2]);
- const curType = listType[1];
- // Upstream #7447: use the regex capture group, not the full match,
- // so indent-type lines are correctly detected during renumbering.
- if (isNaN(curLevel) || listType[1] === 'indent') {
- return line;
- } else if (curLevel === level) {
- // Upstream #7436: reset position when switching between list types
- // at the same level (e.g., bullet -> number) so OL following UL
- // starts at 1.
- if (prevType && prevType !== curType) {
- position = 1;
- }
- prevType = curType;
-
- buildKeepRange(rep, builder, loc, (loc = [line, 0]));
- buildKeepRange(rep, builder, loc, (loc = [line, 1]), [
- ['start', position],
- ], rep.apool);
-
- position++;
- line++;
- } else if (curLevel < level) {
- return line;// back to parent
- } else {
- line = applyNumberList(line, level + 1);// recursive call
- }
- }
- return line;
- };
-
- applyNumberList(lineNum, 1);
- const cs = builder.toString();
- if (!isIdentity(cs)) {
- performDocumentApplyChangeset(cs);
- }
-
- // 4-apply the modifications
- };
- editorInfo.ace_renumberList = renumberList;
-
- const setLineListType = (lineNum, listType) => {
- if (listType === '') {
- documentAttributeManager.removeAttributeOnLine(lineNum, listAttributeName);
- documentAttributeManager.removeAttributeOnLine(lineNum, 'start');
- } else {
- documentAttributeManager.setAttributeOnLine(lineNum, listAttributeName, listType);
- }
-
- // if the list has been removed, it is necessary to renumber
- // starting from the *next* line because the list may have been
- // separated. If it returns null, it means that the list was not cut, try
- // from the current one.
- if (renumberList(lineNum + 1) == null) {
- renumberList(lineNum);
- }
- };
-
- const doReturnKey = () => {
- if (!(rep.selStart && rep.selEnd)) {
- return;
- }
-
- const lineNum = rep.selStart[0];
- let listType = getLineListType(lineNum);
-
- if (listType) {
- const text = rep.lines.atIndex(lineNum).text;
- listType = /([a-z]+)([0-9]+)/.exec(listType);
- const type = listType[1];
- const level = Number(listType[2]);
-
- // detect empty list item; exclude indentation
- if (text === '*' && type !== 'indent') {
- // if not already on the highest level
- if (level > 1) {
- setLineListType(lineNum, type + (level - 1));// automatically decrease the level
- } else {
- setLineListType(lineNum, '');// remove the list
- renumberList(lineNum + 1);// trigger renumbering of list that may be right after
- }
- } else if (lineNum + 1 <= rep.lines.length()) {
- performDocumentReplaceSelection('\n');
- setLineListType(lineNum + 1, type + level);
- }
- } else {
- performDocumentReplaceSelection('\n');
- handleReturnIndentation();
- }
- };
- editorInfo.ace_doReturnKey = doReturnKey;
-
- const doIndentOutdent = (isOut) => {
- if (!((rep.selStart && rep.selEnd) ||
- (rep.selStart[0] === rep.selEnd[0] &&
- rep.selStart[1] === rep.selEnd[1] &&
- rep.selEnd[1] > 1)) &&
- isOut !== true) {
- return false;
- }
-
- const firstLine = rep.selStart[0];
- const lastLine = Math.max(firstLine, rep.selEnd[0] - ((rep.selEnd[1] === 0) ? 1 : 0));
- const mods = [];
- for (let n = firstLine; n <= lastLine; n++) {
- let listType = getLineListType(n);
- let t = 'indent';
- let level = 0;
- if (listType) {
- listType = /([a-z]+)([0-9]+)/.exec(listType);
- if (listType) {
- t = listType[1];
- level = Number(listType[2]);
- }
- }
- const newLevel = Math.max(0, Math.min(MAX_LIST_LEVEL, level + (isOut ? -1 : 1)));
- if (level !== newLevel) {
- mods.push([n, (newLevel > 0) ? t + newLevel : '']);
- }
- }
-
- for (const mod of mods) setLineListType(mod[0], mod[1]);
- return true;
- };
- editorInfo.ace_doIndentOutdent = doIndentOutdent;
-
- const doTabKey = (shiftDown) => {
- if (!doIndentOutdent(shiftDown)) {
- performDocumentReplaceSelection(THE_TAB);
- }
- };
-
- const doDeleteKey = (optEvt) => {
- const evt = optEvt || {};
- let handled = false;
- if (rep.selStart) {
- if (isCaret()) {
- const lineNum = caretLine();
- const col = caretColumn();
- const lineEntry = rep.lines.atIndex(lineNum);
- const lineText = lineEntry.text;
- const lineMarker = lineEntry.lineMarker;
- if (evt.metaKey && col > lineMarker) {
- // cmd-backspace deletes to start of line (if not already at start)
- performDocumentReplaceRange([lineNum, lineMarker], [lineNum, col], '');
- handled = true;
- } else if (/^ +$/.exec(lineText.substring(lineMarker, col))) {
- const col2 = col - lineMarker;
- const tabSize = THE_TAB.length;
- const toDelete = ((col2 - 1) % tabSize) + 1;
- performDocumentReplaceRange([lineNum, col - toDelete], [lineNum, col], '');
- handled = true;
- }
- }
- if (!handled) {
- if (isCaret()) {
- const theLine = caretLine();
- const lineEntry = rep.lines.atIndex(theLine);
- if (caretColumn() <= lineEntry.lineMarker) {
- // delete at beginning of line
- const prevLineListType = (theLine > 0 ? getLineListType(theLine - 1) : '');
- const thisLineListType = getLineListType(theLine);
- const prevLineEntry = (theLine > 0 && rep.lines.atIndex(theLine - 1));
- const prevLineBlank = (prevLineEntry &&
- prevLineEntry.text.length === prevLineEntry.lineMarker);
-
- const thisLineHasMarker = documentAttributeManager.lineHasMarker(theLine);
-
- if (thisLineListType) {
- // this line is a list
- if (prevLineBlank && !prevLineListType) {
- // previous line is blank, remove it
- performDocumentReplaceRange(
- [theLine - 1, prevLineEntry.text.length], [theLine, 0], '');
- } else {
- // delistify
- performDocumentReplaceRange([theLine, 0], [theLine, lineEntry.lineMarker], '');
- }
- } else if (thisLineHasMarker && prevLineEntry) {
- // If the line has any attributes assigned, remove them by removing the marker '*'
- performDocumentReplaceRange(
- [theLine - 1, prevLineEntry.text.length], [theLine, lineEntry.lineMarker], '');
- } else if (theLine > 0) {
- // remove newline
- performDocumentReplaceRange(
- [theLine - 1, prevLineEntry.text.length], [theLine, 0], '');
- }
- } else {
- const docChar = caretDocChar();
- if (docChar > 0) {
- if (evt.metaKey || evt.ctrlKey || evt.altKey) {
- // delete as many unicode "letters or digits" in a row as possible;
- // always delete one char, delete further even if that first char
- // isn't actually a word char.
- let deleteBackTo = docChar - 1;
- while (deleteBackTo > lineEntry.lineMarker &&
- isWordChar(rep.alltext.charAt(deleteBackTo - 1))) {
- deleteBackTo--;
- }
- performDocumentReplaceCharRange(deleteBackTo, docChar, '');
- } else {
- // normal delete
- performDocumentReplaceCharRange(docChar - 1, docChar, '');
- }
- }
- }
- } else {
- performDocumentReplaceSelection('');
- }
- }
- }
- // if the list has been removed, it is necessary to renumber
- // starting from the *next* line because the list may have been
- // separated. If it returns null, it means that the list was not cut, try
- // from the current one.
- const line = caretLine();
- if (line !== -1 && renumberList(line + 1) == null) {
- renumberList(line);
- }
- };
-
- const isWordChar = (c) => padutils.wordCharRegex.test(c);
- editorInfo.ace_isWordChar = isWordChar;
-
- const handleKeyEvent = (evt) => {
- if (!isEditable) return;
- const {type, charCode, keyCode, which, shiftKey} = evt;
-
- // If DOM3 support exists, ensure that the left ALT key was pressed. This
- // allows keyboard layouts with special meaning for right-alt-char to
- // continue working on Firefox / macOS.
- let altKey = evt.altKey;
- if (typeof evt.location === 'number') {
- altKey = altKey && evt.location === KeyboardEvent.DOM_KEY_LOCATION_LEFT;
- }
-
- // Don't take action based on modifier keys going up and down.
- // Modifier keys do not generate "keypress" events.
- // 224 is the command-key under Mac Firefox.
- // 91 is the Windows key in IE; it is ASCII for open-bracket but isn't the keycode for that key
- // 20 is capslock in IE.
- const isModKey = !charCode && (type === 'keyup' || type === 'keydown') &&
- (keyCode === 16 || keyCode === 17 || keyCode === 18 ||
- keyCode === 20 || keyCode === 224 || keyCode === 91);
- if (isModKey) return;
-
- // If the key is a keypress and the browser is opera and the key is enter,
- // do nothign at all as this fires twice.
- if (keyCode === 13 && browser.opera && type === 'keypress') {
- // This stops double enters in Opera but double Tabs still show on single
- // tab keypress, adding keyCode == 9 to this doesn't help as the event is fired twice
- return;
- }
-
- const isTypeForSpecialKey = browser.safari || browser.chrome || browser.firefox
- ? type === 'keydown' : type === 'keypress';
- const isTypeForCmdKey = browser.safari || browser.chrome || browser.firefox
- ? type === 'keydown' : type === 'keypress';
-
- let stopped = false;
-
- inCallStackIfNecessary('handleKeyEvent', function () {
- if (type === 'keypress' || (isTypeForSpecialKey && keyCode === 13 /* return*/)) {
- // in IE, special keys don't send keypress, the keydown does the action
- if (!outsideKeyPress(evt)) {
- evt.preventDefault();
- stopped = true;
- }
- } else if (evt.key === 'Dead') {
- // If it's a dead key we don't want to do any Etherpad behavior.
- stopped = true;
- return true;
- } else if (type === 'keydown') {
- outsideKeyDown(evt);
- }
- let specialHandled = false;
- if (!stopped) {
- const padShortcutEnabled = window.clientVars.padShortcutEnabled;
- if (!specialHandled && isTypeForSpecialKey &&
- altKey && keyCode === 120 &&
- padShortcutEnabled.altF9) {
- // Alt F9 focuses on the File Menu and/or editbar.
- // Note that while most editors use Alt F10 this is not desirable
- // As ubuntu cannot use Alt F10....
- // Focus on the editbar.
- // -- TODO: Move Focus back to previous state (we know it so we can use it)
- if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
- const firstEditbarElement = document.querySelector('#editbar ul li a button');
- firstEditbarElement?.focus();
- evt.preventDefault();
- }
- if (!specialHandled && type === 'keydown' &&
- altKey && keyCode === 67 &&
- padShortcutEnabled.altC) {
- // Alt c focuses on the Chat window
- if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
- window.chat.show();
- document.getElementById('chatinput')?.focus();
- evt.preventDefault();
- }
- if (!specialHandled && type === 'keydown' &&
- evt.ctrlKey && shiftKey && keyCode === 50 &&
- padShortcutEnabled.cmdShift2) {
- // Control-Shift-2 shows a gritter popup showing a line author
- const lineNumber = rep.selEnd[0];
- const alineAttrs = rep.alines[lineNumber];
- const apool = rep.apool;
-
- // TODO: support selection ranges
- // TODO: Still work when authorship colors have been cleared
- // TODO: i18n
- // TODO: There appears to be a race condition or so.
- const authorIds = new Set();
- if (alineAttrs) {
- for (const op of deserializeOps(alineAttrs)) {
- const authorId = AttributeMap.fromString(op.attribs, apool).get('author');
- if (authorId) authorIds.add(authorId);
- }
- }
- const idToName = new Map(window.pad.userList().map((a) => [a.userId, a.name]));
- const myId = window.clientVars.userId;
- const authors =
- [...authorIds].map((id) => id === myId ? 'me' : idToName.get(id) || 'unknown');
-
- notifications.add({
- title: 'Line Authors',
- text:
- authors.length === 0 ? 'No author information is available'
- : authors.length === 1 ? `The author of this line is ${authors[0]}`
- : `The authors of this line are ${authors.join(' & ')}`,
- sticky: false,
- time: '4000',
- });
- }
- if (!specialHandled && isTypeForSpecialKey &&
- keyCode === 8 &&
- padShortcutEnabled.delete) {
- // "delete" key; in mozilla, if we're at the beginning of a line, normalize now,
- // or else deleting a blank line can take two delete presses.
- // --
- // we do deletes completely customly now:
- // - allows consistent (and better) meta-delete behavior
- // - normalizing and then allowing default behavior confused IE
- // - probably eliminates a few minor quirks
- fastIncorp(3);
- evt.preventDefault();
- doDeleteKey(evt);
- specialHandled = true;
- }
- if (!specialHandled && isTypeForSpecialKey &&
- keyCode === 13 &&
- padShortcutEnabled.return) {
- // return key, handle specially;
- // note that in mozilla we need to do an incorporation for proper return behavior anyway.
- fastIncorp(4);
- evt.preventDefault();
- doReturnKey();
- scheduler.setTimeout(() => {
- outerWin.scrollBy(-100, 0);
- }, 0);
- specialHandled = true;
- }
- if (!specialHandled && isTypeForSpecialKey &&
- keyCode === 27 &&
- padShortcutEnabled.esc) {
- // prevent esc key;
- // in mozilla versions 14-19 avoid reconnecting pad.
-
- fastIncorp(4);
- evt.preventDefault();
- specialHandled = true;
-
- // close all gritters when the user hits escape key
- notifications.removeAll();
- }
- if (!specialHandled && isTypeForCmdKey &&
- /* Do a saved revision on ctrl S */
- (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 's' &&
- !evt.altKey &&
- padShortcutEnabled.cmdS) {
- evt.preventDefault();
- const revisionLink = document.getElementById('revisionlink');
- const originalBackground = revisionLink instanceof HTMLElement ? revisionLink.style.background : '';
- if (revisionLink instanceof HTMLElement) revisionLink.style.background = 'lightyellow';
- scheduler.setTimeout(() => {
- if (revisionLink instanceof HTMLElement) revisionLink.style.background = originalBackground;
- }, 1000);
-
- window.pad.collabClient.sendMessage({type: 'SAVE_REVISION'});
- specialHandled = true;
- }
- if (!specialHandled && isTypeForSpecialKey &&
- // tab
- keyCode === 9 &&
- !(evt.metaKey || evt.ctrlKey) &&
- padShortcutEnabled.tab) {
- fastIncorp(5);
- evt.preventDefault();
- doTabKey(evt.shiftKey);
- specialHandled = true;
- }
- if (!specialHandled && isTypeForCmdKey &&
- // cmd-Z (undo)
- (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'z' &&
- !evt.altKey &&
- padShortcutEnabled.cmdZ) {
- fastIncorp(6);
- evt.preventDefault();
- if (evt.shiftKey) {
- doUndoRedo('redo');
- } else {
- doUndoRedo('undo');
- }
- specialHandled = true;
- }
- if (!specialHandled && isTypeForCmdKey &&
- // cmd-Y (redo)
- (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'y' &&
- padShortcutEnabled.cmdY) {
- fastIncorp(10);
- evt.preventDefault();
- doUndoRedo('redo');
- specialHandled = true;
- }
- if (!specialHandled && isTypeForCmdKey &&
- // cmd-B (bold)
- (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'b' &&
- padShortcutEnabled.cmdB) {
- fastIncorp(13);
- evt.preventDefault();
- toggleAttributeOnSelection('bold');
- specialHandled = true;
- }
- if (!specialHandled && isTypeForCmdKey &&
- // cmd-I (italic)
- (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'i' &&
- padShortcutEnabled.cmdI) {
- fastIncorp(14);
- evt.preventDefault();
- toggleAttributeOnSelection('italic');
- specialHandled = true;
- }
- if (!specialHandled && isTypeForCmdKey &&
- // cmd-U (underline)
- (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'u' &&
- padShortcutEnabled.cmdU) {
- fastIncorp(15);
- evt.preventDefault();
- toggleAttributeOnSelection('underline');
- specialHandled = true;
- }
- if (!specialHandled && isTypeForCmdKey &&
- // cmd-5 (strikethrough)
- (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === '5' &&
- evt.altKey !== true &&
- padShortcutEnabled.cmd5) {
- fastIncorp(13);
- evt.preventDefault();
- toggleAttributeOnSelection('strikethrough');
- specialHandled = true;
- }
- if (!specialHandled && isTypeForCmdKey &&
- // cmd-shift-L (unorderedlist)
- (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'l' &&
- evt.shiftKey &&
- padShortcutEnabled.cmdShiftL) {
- fastIncorp(9);
- evt.preventDefault();
- doInsertUnorderedList();
- specialHandled = true;
- }
- if (!specialHandled && isTypeForCmdKey &&
- // cmd-shift-N and cmd-shift-1 (orderedlist)
- (evt.metaKey || evt.ctrlKey) && evt.shiftKey &&
- ((String.fromCharCode(which).toLowerCase() === 'n' && padShortcutEnabled.cmdShiftN) ||
- (String.fromCharCode(which) === '1' && padShortcutEnabled.cmdShift1))) {
- fastIncorp(9);
- evt.preventDefault();
- doInsertOrderedList();
- specialHandled = true;
- }
- if (!specialHandled && isTypeForCmdKey &&
- // cmd-shift-C (clearauthorship)
- (evt.metaKey || evt.ctrlKey) && evt.shiftKey &&
- String.fromCharCode(which).toLowerCase() === 'c' &&
- padShortcutEnabled.cmdShiftC) {
- fastIncorp(9);
- evt.preventDefault();
- CMDS.clearauthorship();
- }
- if (!specialHandled && isTypeForCmdKey &&
- // cmd-H (backspace)
- (evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'h' &&
- padShortcutEnabled.cmdH) {
- fastIncorp(20);
- evt.preventDefault();
- doDeleteKey();
- specialHandled = true;
- }
- if (evt.ctrlKey === true && evt.which === 36 &&
- // Control Home send to Y = 0
- padShortcutEnabled.ctrlHome) {
- scroll.setScrollY(0);
- }
- if ((evt.which === 33 || evt.which === 34) && type === 'keydown' && !evt.ctrlKey) {
- // This is required, browsers will try to do normal default behavior on
- // page up / down and the default behavior SUCKS
- evt.preventDefault();
- const oldVisibleLineRange = scroll.getVisibleLineRange(rep);
- let topOffset = rep.selStart[0] - oldVisibleLineRange[0];
- if (topOffset < 0) {
- topOffset = 0;
- }
-
- const isPageDown = evt.which === 34;
- const isPageUp = evt.which === 33;
-
- scheduler.setTimeout(() => {
- // the visible lines IE 1,10
- const newVisibleLineRange = scroll.getVisibleLineRange(rep);
- // total count of lines in pad IE 10
- const linesCount = rep.lines.length();
- // How many lines are in the viewport right now?
- const numberOfLinesInViewport = newVisibleLineRange[1] - newVisibleLineRange[0];
-
- if (isPageUp && padShortcutEnabled.pageUp) {
- // move to the bottom line +1 in the viewport (essentially skipping over a page)
- rep.selEnd[0] -= numberOfLinesInViewport;
- // move to the bottom line +1 in the viewport (essentially skipping over a page)
- rep.selStart[0] -= numberOfLinesInViewport;
- }
-
- // if we hit page down
- if (isPageDown && padShortcutEnabled.pageDown) {
- // If the new viewpoint position is actually further than where we are right now
- if (rep.selEnd[0] >= oldVisibleLineRange[0]) {
- // dont go further in the page down than what's visible IE go from 0 to 50
- // if 50 is visible on screen but dont go below that else we miss content
- rep.selStart[0] = oldVisibleLineRange[1] - 1;
- // dont go further in the page down than what's visible IE go from 0 to 50
- // if 50 is visible on screen but dont go below that else we miss content
- rep.selEnd[0] = oldVisibleLineRange[1] - 1;
- }
- }
-
- // ensure min and max
- if (rep.selEnd[0] < 0) {
- rep.selEnd[0] = 0;
- }
- if (rep.selStart[0] < 0) {
- rep.selStart[0] = 0;
- }
- if (rep.selEnd[0] >= linesCount) {
- rep.selEnd[0] = linesCount - 1;
- }
- updateBrowserSelectionFromRep();
- // get the current caret selection, can't use rep. here because that only gives
- // us the start position not the current
- const myselection = targetDoc.getSelection();
- // get the carets selection offset in px IE 214
- let caretOffsetTop = myselection.focusNode.parentNode.offsetTop ||
- myselection.focusNode.offsetTop;
-
- // sometimes the first selection is -1 which causes problems
- // (Especially with ep_page_view)
- // so use focusNode.offsetTop value.
- if (caretOffsetTop === -1) caretOffsetTop = myselection.focusNode.offsetTop;
- // set the scrollY offset of the viewport on the document
- scroll.setScrollY(caretOffsetTop);
- }, 200);
- }
- }
-
- if (type === 'keydown') {
- idleWorkTimer.atLeast(500);
- } else if (type === 'keypress') {
- // OPINION ASKED. What's going on here? :D
- if (!specialHandled) {
- idleWorkTimer.atMost(0);
- } else {
- idleWorkTimer.atLeast(500);
- }
- } else if (type === 'keyup') {
- const wait = 0;
- idleWorkTimer.atLeast(wait);
- idleWorkTimer.atMost(wait);
- }
-
- // Is part of multi-keystroke international character on Firefox Mac
- const isFirefoxHalfCharacter =
- (browser.firefox && evt.altKey && charCode === 0 && keyCode === 0);
-
- // Is part of multi-keystroke international character on Safari Mac
- const isSafariHalfCharacter =
- (browser.safari && evt.altKey && keyCode === 229);
-
- // Upstream #7459: keyCode 229 indicates an IME/composition event
- // (dead keys, compose key). On Firefox Linux, the keydown for a dead
- // key fires before compositionstart, so inInternationalComposition
- // may not yet be set — treat it as a half-character input to keep
- // observeChangesAroundSelection from eating the preceding space.
- const isCompositionKeyCode = (keyCode === 229);
-
- if (thisKeyDoesntTriggerNormalize || isFirefoxHalfCharacter || isSafariHalfCharacter || isCompositionKeyCode) {
- idleWorkTimer.atLeast(3000); // give user time to type
- // if this is a keydown, e.g., the keyup shouldn't trigger a normalize
- thisKeyDoesntTriggerNormalize = true;
- }
-
- if (!specialHandled && !thisKeyDoesntTriggerNormalize && !inInternationalComposition &&
- type !== 'keyup') {
- observeChangesAroundSelection();
- }
-
- if (type === 'keyup') {
- thisKeyDoesntTriggerNormalize = false;
- }
- });
- };
-
- let thisKeyDoesntTriggerNormalize = false;
-
- const doUndoRedo = (which) => {
- // precond: normalized DOM
- if (undoModule.enabled) {
- let whichMethod;
- if (which === 'undo') whichMethod = 'performUndo';
- if (which === 'redo') whichMethod = 'performRedo';
- if (whichMethod) {
- const oldEventType = currentCallStack.editEvent.eventType;
- currentCallStack.startNewEvent(which);
- undoModule[whichMethod]((backset, selectionInfo) => {
- if (backset) {
- performDocumentApplyChangeset(backset);
- }
- if (selectionInfo) {
- performSelectionChange(
- lineAndColumnFromChar(selectionInfo.selStart),
- lineAndColumnFromChar(selectionInfo.selEnd),
- selectionInfo.selFocusAtStart);
- }
- const oldEvent = currentCallStack.startNewEvent(oldEventType, true);
- return oldEvent;
- });
- }
- }
- };
- editorInfo.ace_doUndoRedo = doUndoRedo;
-
- const setSelection = (selection) => {
- const copyPoint = (pt) => ({
- node: pt.node,
- index: pt.index,
- maxIndex: pt.maxIndex,
- });
- let isCollapsed;
-
- const pointToRangeBound = (pt) => {
- const p = copyPoint(pt);
- // Make sure Firefox cursor is deep enough; fixes cursor jumping when at top level,
- // and also problem where cut/copy of a whole line selected with fake arrow-keys
- // copies the next line too.
- if (isCollapsed) {
- const diveDeep = () => {
- while (p.node.childNodes.length > 0) {
- if (p.index === 0) {
- p.node = p.node.firstChild;
- p.maxIndex = nodeMaxIndex(p.node);
- } else if (p.index === p.maxIndex) {
- p.node = p.node.lastChild;
- p.maxIndex = nodeMaxIndex(p.node);
- p.index = p.maxIndex;
- } else { break; }
- }
- };
- // now fix problem where cursor at end of text node at end of span-like element
- // with background doesn't seem to show up...
- if (isNodeText(p.node) && p.index === p.maxIndex) {
- let n = p.node;
- while (!n.nextSibling && n !== targetBody && n.parentNode !== targetBody) {
- n = n.parentNode;
- }
- if (n.nextSibling &&
- !(typeof n.nextSibling.tagName === 'string' &&
- n.nextSibling.tagName.toLowerCase() === 'br') &&
- n !== p.node && n !== targetBody && n.parentNode !== targetBody) {
- // found a parent, go to next node and dive in
- p.node = n.nextSibling;
- p.maxIndex = nodeMaxIndex(p.node);
- p.index = 0;
- diveDeep();
- }
- }
- // try to make sure insertion point is styled;
- // also fixes other FF problems
- if (!isNodeText(p.node)) {
- diveDeep();
- }
- }
- if (isNodeText(p.node)) {
- return {
- container: p.node,
- offset: p.index,
- };
- } else {
- // p.index in {0,1}
- return {
- container: p.node.parentNode,
- offset: childIndex(p.node) + p.index,
- };
- }
- };
- const browserSelection = targetDoc.getSelection();
- if (browserSelection) {
- browserSelection.removeAllRanges();
- if (selection) {
- isCollapsed = (selection.startPoint.node === selection.endPoint.node &&
- selection.startPoint.index === selection.endPoint.index);
- const start = pointToRangeBound(selection.startPoint);
- const end = pointToRangeBound(selection.endPoint);
-
- if (!isCollapsed && selection.focusAtStart &&
- browserSelection.collapse && browserSelection.extend) {
- // can handle "backwards"-oriented selection, shift-arrow-keys move start
- // of selection
- browserSelection.collapse(end.container, end.offset);
- browserSelection.extend(start.container, start.offset);
- } else {
- const range = document.createRange();
- range.setStart(start.container, start.offset);
- range.setEnd(end.container, end.offset);
- browserSelection.removeAllRanges();
- browserSelection.addRange(range);
- }
- }
- }
- };
-
- const updateBrowserSelectionFromRep = () => {
- // requires normalized DOM!
- const selStart = rep.selStart;
- const selEnd = rep.selEnd;
-
- if (!(selStart && selEnd)) {
- setSelection(null);
- return;
- }
-
- const selection = {};
-
- const ss = [selStart[0], selStart[1]];
- selection.startPoint = getPointForLineAndChar(ss);
-
- const se = [selEnd[0], selEnd[1]];
- selection.endPoint = getPointForLineAndChar(se);
-
- selection.focusAtStart = !!rep.selFocusAtStart;
- setSelection(selection);
- };
- editorInfo.ace_updateBrowserSelectionFromRep = updateBrowserSelectionFromRep;
- editorInfo.ace_focus = focus;
- editorInfo.ace_importText = importText;
- editorInfo.ace_importAText = importAText;
- editorInfo.ace_exportText = exportText;
- editorInfo.ace_editorChangedSize = editorChangedSize;
- editorInfo.ace_setOnKeyPress = setOnKeyPress;
- editorInfo.ace_setOnKeyDown = setOnKeyDown;
- editorInfo.ace_setNotifyDirty = setNotifyDirty;
- editorInfo.ace_dispose = dispose;
- editorInfo.ace_setEditable = setEditable;
- editorInfo.ace_execCommand = execCommand;
- editorInfo.ace_replaceRange = replaceRange;
- editorInfo.ace_getAuthorInfos = getAuthorInfos;
- editorInfo.ace_performDocumentReplaceRange = performDocumentReplaceRange;
- editorInfo.ace_performDocumentReplaceCharRange = performDocumentReplaceCharRange;
- editorInfo.ace_setSelection = setSelection;
-
- const nodeMaxIndex = (nd) => {
- if (isNodeText(nd)) return nd.nodeValue.length;
- else return 1;
- };
-
- const getSelection = () => {
- // returns null, or a structure containing startPoint and endPoint,
- // each of which has node (a magicdom node), index, and maxIndex. If the node
- // is a text node, maxIndex is the length of the text; else maxIndex is 1.
- // index is between 0 and maxIndex, inclusive.
- const browserSelection = targetDoc.getSelection();
- if (!browserSelection || browserSelection.type === 'None' ||
- browserSelection.rangeCount === 0) {
- return null;
- }
- const range = browserSelection.getRangeAt(0);
-
- const isInBody = (n) => {
- while (n && !(n.tagName && n.tagName.toLowerCase() === 'body')) {
- n = n.parentNode;
- }
- return !!n;
- };
-
- const pointFromRangeBound = (container, offset) => {
- if (!isInBody(container)) {
- // command-click in Firefox selects whole document, HEAD and BODY!
- return {
- node: targetBody,
- index: 0,
- maxIndex: 1,
- };
- }
- const n = container;
- const childCount = n.childNodes.length;
- if (isNodeText(n)) {
- return {
- node: n,
- index: offset,
- maxIndex: n.nodeValue.length,
- };
- } else if (childCount === 0) {
- return {
- node: n,
- index: 0,
- maxIndex: 1,
- };
- // treat point between two nodes as BEFORE the second (rather than after the first)
- // if possible; this way point at end of a line block-element is treated as
- // at beginning of next line
- } else if (offset === childCount) {
- const nd = n.childNodes.item(childCount - 1);
- const max = nodeMaxIndex(nd);
- return {
- node: nd,
- index: max,
- maxIndex: max,
- };
- } else {
- const nd = n.childNodes.item(offset);
- const max = nodeMaxIndex(nd);
- return {
- node: nd,
- index: 0,
- maxIndex: max,
- };
- }
- };
- const selection = {
- startPoint: pointFromRangeBound(range.startContainer, range.startOffset),
- endPoint: pointFromRangeBound(range.endContainer, range.endOffset),
- focusAtStart:
- (range.startContainer !== range.endContainer || range.startOffset !== range.endOffset) &&
- browserSelection.anchorNode &&
- browserSelection.anchorNode === range.endContainer &&
- browserSelection.anchorOffset === range.endOffset,
- };
-
- if (selection.startPoint.node.ownerDocument !== targetDoc) {
- return null;
- }
-
- return selection;
- };
-
- const childIndex = (n) => {
- let idx = 0;
- while (n.previousSibling) {
- idx++;
- n = n.previousSibling;
- }
- return idx;
- };
-
- const fixView = () => {
- // calling this method repeatedly should be fast
- if (getInnerWidth() === 0 || getInnerHeight() === 0) {
- return;
- }
-
- enforceEditability();
-
- sideDiv.classList.add('sidedivdelayed');
- };
-
- const _teardownActions = [];
-
- const teardown = () => { for (const a of _teardownActions) a(); };
-
- let inInternationalComposition = null;
- editorInfo.ace_getInInternationalComposition = () => inInternationalComposition;
-
- const isLinkTarget = (target) =>
- target instanceof Element && (target.localName === 'a' || target.closest('a') != null);
-
- const bindTheEventHandlers = () => {
- targetDoc.addEventListener('keydown', handleKeyEvent);
- targetDoc.addEventListener('keypress', handleKeyEvent);
- targetDoc.addEventListener('keyup', handleKeyEvent);
- targetDoc.addEventListener('click', handleClick);
- // dropdowns on edit bar need to be closed on clicks on both pad inner and pad outer
- outerDoc.addEventListener('click', hideEditBarDropdowns);
-
- // If non-nullish, pasting on a link should be suppressed.
- let suppressPasteOnLink = null;
-
- targetBody.addEventListener('auxclick', (e) => {
- if (e.button === 1 && isLinkTarget(e.target)) {
- // The user middle-clicked on a link. Usually users do this to open a link in a new tab, but
- // in X11 (Linux) this will instead paste the contents of the primary selection at the mouse
- // cursor. Users almost certainly do not want to paste when middle-clicking on a link, so
- // tell the 'paste' event handler to suppress the paste. This is done by starting a
- // short-lived timer that suppresses paste (when the target is a link) until either the
- // paste event arrives or the timer fires.
- //
- // Why it is implemented this way:
- // * Users want to be able to paste on a link via Ctrl-V, the Edit menu, or the context
- // menu (https://github.com/ether/etherpad-lite/issues/2775) so we cannot simply
- // suppress all paste actions when the target is a link.
- // * Non-X11 systems do not paste when the user middle-clicks, so the paste suppression
- // must be self-resetting.
- // * On non-X11 systems, middle click should continue to open the link in a new tab.
- // Suppressing the middle click here in the 'auxclick' handler (via e.preventDefault())
- // would break that behavior.
- suppressPasteOnLink = scheduler.setTimeout(() => { suppressPasteOnLink = null; }, 0);
- }
- });
-
- targetBody.addEventListener('paste', (e: ClipboardEvent) => {
- if (suppressPasteOnLink != null && isLinkTarget(e.target)) {
- scheduler.clearTimeout(suppressPasteOnLink);
- suppressPasteOnLink = null;
- e.preventDefault();
- return;
- }
-
- // EventBus: notify listeners about paste events
- editorBus.emit('custom:ace:paste', {
- editorInfo,
- rep,
- documentAttributeManager,
- e,
- });
-
- // Upstream #7460: preserve inline formatting (bold/italic/etc.) when
- // pasting. Browser contentEditable normalization strips nested ace-line
- // wrappers and loses the formatting tags before Etherpad's content
- // collector sees them. Extract HTML from the clipboard ourselves,
- // sanitize it, and insert it directly so the collector sees intact tags.
- const clipboardData = e.clipboardData || (window as any).clipboardData;
- const pastedHtml = clipboardData?.getData && clipboardData.getData('text/html');
- if (pastedHtml) {
- const parser = new DOMParser();
- const doc = parser.parseFromString(pastedHtml, 'text/html');
- const hasFormatting = doc.querySelector('b, strong, i, em, u, s, del, ins');
- if (hasFormatting) {
- e.preventDefault();
-
- // Strip dangerous elements + event handlers to prevent XSS.
- // DOMParser doesn't execute scripts but importNode copies attributes.
- for (const el of doc.body.querySelectorAll(
- 'script, style, iframe, object, embed, form, link, meta')) {
- el.remove();
- }
- for (const el of doc.body.querySelectorAll('*')) {
- for (const attr of Array.from(el.attributes)) {
- if (attr.name.startsWith('on') ||
- (attr.name === 'href' && /^\s*javascript:/i.test(attr.value))) {
- el.removeAttribute(attr.name);
- }
- }
- }
-
- const sel = targetDoc.getSelection();
- if (sel && sel.rangeCount > 0) {
- const range = sel.getRangeAt(0);
- range.deleteContents();
- const frag = targetDoc.createDocumentFragment();
- for (const child of Array.from(doc.body.childNodes)) {
- frag.appendChild(targetDoc.importNode(child, true));
- }
- range.insertNode(frag);
- range.collapse(false);
- sel.removeAllRanges();
- sel.addRange(range);
- }
- scheduler.setTimeout(() => {
- inCallStackIfNecessary('paste', () => {
- incorporateUserChanges();
- });
- }, 0);
- }
- }
- });
-
- // We reference document here, this is because if we don't this will expose a bug
- // in Google Chrome. This bug will cause the last character on the last line to
- // not fire an event when dropped into..
- targetBody.addEventListener('drop', (e) => {
- if (isLinkTarget(e.target)) {
- e.preventDefault();
- }
-
- // Bug fix: when user drags some content and drop it far from its origin, we
- // need to merge the changes into a single changeset. So mark origin with
-
- ${colors.map((color, i) => this._renderSwatch(color, i)).join('')}
-
- `;
-
- // Attach event listeners.
- const swatches = this._shadow.querySelectorAll('.swatch');
- swatches.forEach((swatch) => {
- swatch.addEventListener('click', () => {
- const color = swatch.dataset.color!;
- const index = parseInt(swatch.dataset.index!, 10);
- this._selectColor(color, index);
- });
-
- swatch.addEventListener('keydown', (e: KeyboardEvent) => {
- this._handleSwatchKeydown(e, swatches);
- });
- });
- }
-
- private _renderSwatch(color: string, index: number): string {
- const isSelected = this._value === color;
- const light = isLightColor(color);
- const checkColor = light ? '#000' : '#fff';
-
- return `
-
-
- ${this._escapeHtml(color)}
-
- `;
- }
-
- private _selectColor(color: string, index: number): void {
- this._value = color;
- this.setAttribute('value', color);
- this._updateSelection();
-
- this.dispatchEvent(
- new CustomEvent('ep-color-select', {
- bubbles: true,
- composed: true,
- detail: {color, index},
- }),
- );
- }
-
- private _updateSelection(): void {
- const swatches = this._shadow.querySelectorAll('.swatch');
- swatches.forEach((swatch) => {
- const isSelected = swatch.dataset.color === this._value;
- swatch.setAttribute('aria-selected', String(isSelected));
- });
- }
-
- private _handleSwatchKeydown(e: KeyboardEvent, swatches: NodeListOf): void {
- const current = e.currentTarget as HTMLElement;
- const items = Array.from(swatches);
- const idx = items.indexOf(current);
- let nextIdx = -1;
-
- switch (e.key) {
- case 'ArrowRight':
- case 'ArrowDown':
- nextIdx = (idx + 1) % items.length;
- break;
- case 'ArrowLeft':
- case 'ArrowUp':
- nextIdx = (idx - 1 + items.length) % items.length;
- break;
- case 'Home':
- nextIdx = 0;
- break;
- case 'End':
- nextIdx = items.length - 1;
- break;
- case 'Enter':
- case ' ':
- e.preventDefault();
- current.click();
- return;
- default:
- return;
- }
-
- e.preventDefault();
- items[idx].setAttribute('tabindex', '-1');
- items[nextIdx].setAttribute('tabindex', '0');
- items[nextIdx].focus();
- }
-
- private _escapeHtml(text: string): string {
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
- }
-
- private _escapeAttr(text: string): string {
- return text.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''')
- .replace(//g, '>');
- }
-}
-
-customElements.define('ep-color-picker', EpColorPicker);
diff --git a/ui/src/js/components/EpDropdown.ts b/ui/src/js/components/EpDropdown.ts
deleted file mode 100644
index eacc4f34..00000000
--- a/ui/src/js/components/EpDropdown.ts
+++ /dev/null
@@ -1,511 +0,0 @@
-/**
- * EpDropdown + EpDropdownItem — Dropdown menu Web Components for toolbar selects.
- *
- * Usage:
- *
- * Font Size
- *
- * 12px
- * 14px
- *
- *
- */
-
-/* ── Dropdown Item ─────────────────────────────────────────── */
-
-const dropdownItemStyles = /* css */ `
- :host {
- --ep-item-fg: #485365;
- --ep-item-fg: var(--text-color, #485365);
- --ep-item-hover-bg: #f2f3f4;
- --ep-item-hover-bg: var(--bg-soft-color, #f2f3f4);
- --ep-item-font: var(--main-font-family, Quicksand, Cantarell, "Open Sans", "Helvetica Neue", sans-serif);
- --ep-item-focus: #64d29b;
- --ep-item-focus: var(--primary-color, #64d29b);
-
- display: block;
- font-family: var(--ep-item-font);
- font-size: 14px;
- }
-
- .item {
- display: flex;
- align-items: center;
- width: 100%;
- padding: 8px 12px;
- border: none;
- background: none;
- color: var(--ep-item-fg);
- cursor: pointer;
- font: inherit;
- text-align: left;
- white-space: nowrap;
- transition: background 0.1s ease;
- outline: none;
- box-sizing: border-box;
- }
-
- .item:hover,
- .item[aria-selected="true"],
- :host([focused]) .item {
- background: var(--ep-item-hover-bg);
- }
-
- .item:focus-visible {
- background: var(--ep-item-hover-bg);
- outline: 2px solid var(--ep-item-focus);
- outline-offset: -2px;
- }
-
- :host([disabled]) .item {
- opacity: 0.4;
- cursor: not-allowed;
- }
-`;
-
-export class EpDropdownItem extends HTMLElement {
- static observedAttributes = ['value', 'disabled'];
-
- private _shadow: ShadowRoot;
-
- constructor() {
- super();
- this._shadow = this.attachShadow({mode: 'open'});
- }
-
- connectedCallback(): void {
- this._render();
- this.setAttribute('role', 'option');
- }
-
- attributeChangedCallback(): void {
- this._updateState();
- }
-
- get value(): string {
- return this.getAttribute('value') ?? '';
- }
-
- set value(v: string) {
- this.setAttribute('value', v);
- }
-
- get disabled(): boolean {
- return this.hasAttribute('disabled');
- }
-
- set disabled(v: boolean) {
- if (v) {
- this.setAttribute('disabled', '');
- } else {
- this.removeAttribute('disabled');
- }
- }
-
- /** Called by the parent dropdown to visually mark this item as focused. */
- setFocused(focused: boolean): void {
- if (focused) {
- this.setAttribute('focused', '');
- } else {
- this.removeAttribute('focused');
- }
- }
-
- private _render(): void {
- this._shadow.innerHTML = `
-
-
-
-
- `;
- }
-
- private _updateState(): void {
- const itemEl = this._shadow.querySelector('.item');
- if (itemEl && this.disabled) {
- itemEl.setAttribute('aria-disabled', 'true');
- } else if (itemEl) {
- itemEl.removeAttribute('aria-disabled');
- }
- }
-}
-
-customElements.define('ep-dropdown-item', EpDropdownItem);
-
-/* ── Dropdown Container ────────────────────────────────────── */
-
-const dropdownStyles = /* css */ `
- :host {
- --ep-dd-bg: #fff;
- --ep-dd-bg: var(--bg-color, #fff);
- --ep-dd-border: #d2d2d2;
- --ep-dd-border: var(--border-color, #d2d2d2);
- --ep-dd-radius: 8px;
- --ep-dd-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
- --ep-dd-font: var(--main-font-family, Quicksand, Cantarell, "Open Sans", "Helvetica Neue", sans-serif);
-
- display: inline-block;
- position: relative;
- font-family: var(--ep-dd-font);
- font-size: 14px;
- }
-
- .trigger-wrapper {
- display: inline-flex;
- }
-
- .content-wrapper {
- display: none;
- position: fixed;
- top: 0;
- left: 0;
- min-width: 140px;
- max-height: 280px;
- overflow-y: auto;
- background: var(--ep-dd-bg);
- border: 1px solid var(--ep-dd-border);
- border-radius: var(--ep-dd-radius);
- box-shadow: var(--ep-dd-shadow);
- z-index: 9999;
- padding: 4px 0;
- opacity: 0;
- transform: translateY(-4px);
- transition: opacity 0.15s ease, transform 0.15s ease;
- }
-
- :host([open]) .content-wrapper {
- display: block;
- }
-
- :host([open]) .content-wrapper.visible {
- opacity: 1;
- transform: translateY(0);
- }
-
- :host([align="right"]) .content-wrapper {
- right: 0;
- left: auto;
- }
-
- :host([align="left"]) .content-wrapper,
- :host(:not([align])) .content-wrapper {
- left: 0;
- right: auto;
- }
-`;
-
-type TriggerMode = 'click' | 'hover';
-
-export class EpDropdown extends HTMLElement {
- static observedAttributes = ['trigger', 'align', 'open'];
-
- private _shadow: ShadowRoot;
- private _focusIndex = -1;
- private _hoverCloseTimer: ReturnType | null = null;
-
- /* Bound handlers for clean add/remove */
- private _onDocClick = this._handleOutsideClick.bind(this);
- private _onDocKeydown = this._handleDocKeydown.bind(this);
- private _onViewportChange = this._positionContent.bind(this);
-
- constructor() {
- super();
- this._shadow = this.attachShadow({mode: 'open'});
- }
-
- /* ── Lifecycle ────────────────────────────────────────────── */
-
- connectedCallback(): void {
- this._render();
- this._attachTriggerEvents();
-
- // Listen for item clicks from slotted content.
- this.addEventListener('click', (e: Event) => {
- const target = e.target;
- if (target instanceof EpDropdownItem && !target.disabled) {
- this._selectItem(target);
- }
- });
- }
-
- disconnectedCallback(): void {
- document.removeEventListener('click', this._onDocClick, true);
- document.removeEventListener('keydown', this._onDocKeydown);
- window.removeEventListener('resize', this._onViewportChange);
- window.removeEventListener('scroll', this._onViewportChange, true);
- if (this._hoverCloseTimer != null) clearTimeout(this._hoverCloseTimer);
- }
-
- attributeChangedCallback(name: string, _old: string | null, _next: string | null): void {
- if (name === 'open') {
- if (this.isOpen) {
- this._onOpened();
- } else {
- this._onClosed();
- }
- }
- }
-
- /* ── Properties ───────────────────────────────────────────── */
-
- get triggerMode(): TriggerMode {
- return this.getAttribute('trigger') === 'hover' ? 'hover' : 'click';
- }
-
- set triggerMode(v: TriggerMode) {
- this.setAttribute('trigger', v);
- }
-
- get align(): 'left' | 'right' {
- return this.getAttribute('align') === 'right' ? 'right' : 'left';
- }
-
- set align(v: 'left' | 'right') {
- this.setAttribute('align', v);
- }
-
- get isOpen(): boolean {
- return this.hasAttribute('open');
- }
-
- set isOpen(v: boolean) {
- if (v) {
- this.setAttribute('open', '');
- } else {
- this.removeAttribute('open');
- }
- }
-
- /* ── Public ───────────────────────────────────────────────── */
-
- toggle(): void {
- this.isOpen = !this.isOpen;
- }
-
- open(): void {
- this.isOpen = true;
- }
-
- close(): void {
- this.isOpen = false;
- }
-
- /* ── Private ──────────────────────────────────────────────── */
-
- private _render(): void {
- this._shadow.innerHTML = `
-
-
-
-
-
-
-
- `;
- }
-
- private _attachTriggerEvents(): void {
- const triggerSlot = this._shadow.querySelector('slot[name="trigger"]') as HTMLSlotElement;
- const contentWrapper = this._shadow.querySelector('.content-wrapper') as HTMLElement | null;
-
- const preserveEditorSelection = (e: MouseEvent) => {
- // Toolbar clicks should not steal focus from the ACE iframe before the command runs.
- e.preventDefault();
- };
-
- triggerSlot?.addEventListener('mousedown', preserveEditorSelection);
- contentWrapper?.addEventListener('mousedown', preserveEditorSelection);
-
- if (this.triggerMode === 'click') {
- triggerSlot?.addEventListener('click', (e: Event) => {
- e.stopPropagation();
- this.toggle();
- });
- } else {
- // Hover mode.
- this.addEventListener('mouseenter', () => {
- if (this._hoverCloseTimer != null) {
- clearTimeout(this._hoverCloseTimer);
- this._hoverCloseTimer = null;
- }
- this.open();
- });
-
- this.addEventListener('mouseleave', () => {
- this._hoverCloseTimer = setTimeout(() => this.close(), 200);
- });
-
- // Also allow click to toggle in hover mode.
- triggerSlot?.addEventListener('click', (e: Event) => {
- e.stopPropagation();
- this.toggle();
- });
- }
- }
-
- private _onOpened(): void {
- this._focusIndex = -1;
- this._clearItemFocus();
- this._positionContent();
-
- // Animate in.
- requestAnimationFrame(() => {
- const content = this._shadow.querySelector('.content-wrapper');
- this._positionContent();
- content?.classList.add('visible');
- });
-
- document.addEventListener('click', this._onDocClick, true);
- document.addEventListener('keydown', this._onDocKeydown);
- window.addEventListener('resize', this._onViewportChange);
- window.addEventListener('scroll', this._onViewportChange, true);
- }
-
- private _onClosed(): void {
- const content = this._shadow.querySelector('.content-wrapper');
- content?.classList.remove('visible');
- this._focusIndex = -1;
- this._clearItemFocus();
-
- document.removeEventListener('click', this._onDocClick, true);
- document.removeEventListener('keydown', this._onDocKeydown);
- window.removeEventListener('resize', this._onViewportChange);
- window.removeEventListener('scroll', this._onViewportChange, true);
- }
-
- private _positionContent(): void {
- const content = this._shadow.querySelector('.content-wrapper') as HTMLElement | null;
- if (!content || !this.isOpen) return;
-
- const hostRect = this.getBoundingClientRect();
- const viewportPadding = 8;
- const gap = 4;
-
- content.style.minWidth = `${Math.max(140, Math.ceil(hostRect.width))}px`;
- content.style.maxWidth = `${Math.max(140, window.innerWidth - (viewportPadding * 2))}px`;
-
- const contentRect = content.getBoundingClientRect();
- const width = Math.max(contentRect.width, Math.ceil(hostRect.width), 140);
- const height = contentRect.height;
-
- let left = this.align === 'right' ? hostRect.right - width : hostRect.left;
- left = Math.min(Math.max(viewportPadding, left), Math.max(viewportPadding, window.innerWidth - width - viewportPadding));
-
- let top = hostRect.bottom + gap;
- if (top + height > window.innerHeight - viewportPadding) {
- top = Math.max(viewportPadding, hostRect.top - height - gap);
- }
-
- content.style.left = `${Math.round(left)}px`;
- content.style.top = `${Math.round(top)}px`;
- }
-
- private _handleOutsideClick(e: Event): void {
- if (!this.isOpen) return;
- const path = e.composedPath();
- if (!path.includes(this)) {
- this.close();
- }
- }
-
- private _handleDocKeydown(e: KeyboardEvent): void {
- if (!this.isOpen) return;
-
- switch (e.key) {
- case 'Escape':
- e.preventDefault();
- this.close();
- // Return focus to trigger.
- const triggerEl = this.querySelector('[slot="trigger"]');
- triggerEl?.focus();
- break;
-
- case 'ArrowDown':
- e.preventDefault();
- this._moveFocus(1);
- break;
-
- case 'ArrowUp':
- e.preventDefault();
- this._moveFocus(-1);
- break;
-
- case 'Home':
- e.preventDefault();
- this._setFocusIndex(0);
- break;
-
- case 'End': {
- e.preventDefault();
- const items = this._getItems();
- this._setFocusIndex(items.length - 1);
- break;
- }
-
- case 'Enter':
- case ' ': {
- e.preventDefault();
- const items = this._getItems();
- if (this._focusIndex >= 0 && this._focusIndex < items.length) {
- const item = items[this._focusIndex];
- if (!item.disabled) this._selectItem(item);
- }
- break;
- }
- }
- }
-
- private _getItems(): EpDropdownItem[] {
- return Array.from(this.querySelectorAll('ep-dropdown-item'));
- }
-
- private _moveFocus(direction: number): void {
- const items = this._getItems();
- if (items.length === 0) return;
-
- let nextIdx = this._focusIndex + direction;
- // Wrap around.
- if (nextIdx < 0) nextIdx = items.length - 1;
- if (nextIdx >= items.length) nextIdx = 0;
-
- // Skip disabled items.
- const startIdx = nextIdx;
- while (items[nextIdx].disabled) {
- nextIdx += direction;
- if (nextIdx < 0) nextIdx = items.length - 1;
- if (nextIdx >= items.length) nextIdx = 0;
- if (nextIdx === startIdx) return; // All disabled.
- }
-
- this._setFocusIndex(nextIdx);
- }
-
- private _setFocusIndex(index: number): void {
- const items = this._getItems();
- this._clearItemFocus();
- this._focusIndex = index;
- if (index >= 0 && index < items.length) {
- items[index].setFocused(true);
- items[index].scrollIntoView({block: 'nearest'});
- }
- }
-
- private _clearItemFocus(): void {
- for (const item of this._getItems()) {
- item.setFocused(false);
- }
- }
-
- private _selectItem(item: EpDropdownItem): void {
- this.dispatchEvent(
- new CustomEvent('ep-dropdown-select', {
- bubbles: true,
- composed: true,
- detail: {value: item.value},
- }),
- );
- this.close();
- }
-}
-
-customElements.define('ep-dropdown', EpDropdown);
diff --git a/ui/src/js/components/EpModal.ts b/ui/src/js/components/EpModal.ts
deleted file mode 100644
index 430af4bb..00000000
--- a/ui/src/js/components/EpModal.ts
+++ /dev/null
@@ -1,514 +0,0 @@
-/**
- * EpModal — A generic modal/dialog Web Component.
- *
- * Usage:
- *
- * Are you sure?
- *
- * Cancel
- * Confirm
- *
- *
- *
- * Static helpers:
- * const ok = await EpModal.confirm({ title, message })
- * const val = await EpModal.prompt({ title, message, placeholder })
- */
-
-const modalStyles = /* css */ `
- :host {
- --ep-modal-bg: #fff;
- --ep-modal-fg: #171717;
- --ep-modal-border: #e5e5e5;
- --ep-modal-overlay: rgba(0, 0, 0, 0.5);
- --ep-modal-radius: 12px;
- --ep-modal-shadow: 0 16px 48px rgba(0, 0, 0, 0.12);
- --ep-modal-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
- Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
-
- position: fixed;
- inset: 0;
- z-index: 10001;
- display: none;
- align-items: center;
- justify-content: center;
- font-family: var(--ep-modal-font);
- font-size: 14px;
- color: var(--ep-modal-fg);
- }
-
- @media (prefers-color-scheme: dark) {
- :host {
- --ep-modal-bg: #1a1a1a;
- --ep-modal-fg: #e5e5e5;
- --ep-modal-border: #333;
- --ep-modal-overlay: rgba(0, 0, 0, 0.7);
- --ep-modal-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
- }
- }
-
- :host([open]) {
- display: flex;
- }
-
- .overlay {
- position: fixed;
- inset: 0;
- background: var(--ep-modal-overlay);
- animation: ep-modal-fade-in 0.15s ease;
- }
-
- .dialog {
- position: relative;
- z-index: 1;
- background: var(--ep-modal-bg);
- border-radius: var(--ep-modal-radius);
- box-shadow: var(--ep-modal-shadow);
- max-width: 480px;
- width: calc(100vw - 32px);
- max-height: calc(100vh - 64px);
- overflow: auto;
- animation: ep-modal-scale-in 0.2s ease;
- outline: none;
- }
-
- .header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 16px 20px 0;
- }
-
- .title {
- margin: 0;
- font-size: 16px;
- font-weight: 600;
- line-height: 1.4;
- }
-
- .close-btn {
- background: none;
- border: none;
- cursor: pointer;
- padding: 4px;
- margin: -4px -4px 0 8px;
- color: var(--ep-modal-fg);
- opacity: 0.5;
- transition: opacity 0.15s ease;
- font-size: 18px;
- line-height: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 4px;
- }
-
- .close-btn:hover,
- .close-btn:focus-visible {
- opacity: 1;
- }
-
- .close-btn:focus-visible {
- outline: 2px solid currentColor;
- outline-offset: 2px;
- }
-
- .body {
- padding: 16px 20px;
- line-height: 1.6;
- }
-
- .actions {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- gap: 8px;
- padding: 0 20px 16px;
- }
-
- .actions ::slotted(button),
- .actions button {
- padding: 8px 16px;
- border-radius: 6px;
- font-size: 14px;
- font-weight: 500;
- cursor: pointer;
- transition: background 0.15s ease, border-color 0.15s ease;
- font-family: inherit;
- line-height: 1;
- }
-
- /* Built-in button styles for static helpers */
- .btn-cancel {
- background: transparent;
- border: 1px solid var(--ep-modal-border);
- color: var(--ep-modal-fg);
- }
-
- .btn-cancel:hover {
- background: rgba(128, 128, 128, 0.1);
- }
-
- .btn-confirm {
- background: #171717;
- border: 1px solid #171717;
- color: #fff;
- }
-
- .btn-confirm:hover {
- background: #333;
- }
-
- @media (prefers-color-scheme: dark) {
- .btn-confirm {
- background: #e5e5e5;
- border-color: #e5e5e5;
- color: #000;
- }
- .btn-confirm:hover {
- background: #ccc;
- }
- }
-
- .prompt-input {
- width: 100%;
- box-sizing: border-box;
- padding: 8px 12px;
- border: 1px solid var(--ep-modal-border);
- border-radius: 6px;
- font-size: 14px;
- font-family: inherit;
- background: var(--ep-modal-bg);
- color: var(--ep-modal-fg);
- margin-top: 12px;
- outline: none;
- transition: border-color 0.15s ease;
- }
-
- .prompt-input:focus {
- border-color: #666;
- }
-
- @keyframes ep-modal-fade-in {
- from { opacity: 0; }
- to { opacity: 1; }
- }
-
- @keyframes ep-modal-scale-in {
- from { opacity: 0; transform: scale(0.96); }
- to { opacity: 1; transform: scale(1); }
- }
-`;
-
-interface ConfirmOptions {
- title: string;
- message: string;
- confirmText?: string;
- cancelText?: string;
-}
-
-interface PromptOptions {
- title: string;
- message: string;
- placeholder?: string;
-}
-
-export class EpModal extends HTMLElement {
- static observedAttributes = ['open', 'title'];
-
- private _shadow: ShadowRoot;
- private _previousFocus: HTMLElement | null = null;
- private _resolvePromise: ((value: unknown) => void) | null = null;
-
- /* bound handlers for clean add/remove */
- private _onKeyDown = this._handleKeyDown.bind(this);
-
- constructor() {
- super();
- this._shadow = this.attachShadow({mode: 'open'});
- }
-
- /* ── Lifecycle ────────────────────────────────────────────── */
-
- connectedCallback(): void {
- this._render();
- if (this.hasAttribute('open')) {
- this._onOpen();
- }
- }
-
- disconnectedCallback(): void {
- document.removeEventListener('keydown', this._onKeyDown);
- }
-
- attributeChangedCallback(name: string, oldVal: string | null, newVal: string | null): void {
- if (name === 'open') {
- if (newVal != null) {
- this._onOpen();
- } else {
- this._onClose();
- }
- }
- if (name === 'title') {
- const titleEl = this._shadow.querySelector('.title');
- if (titleEl) titleEl.textContent = this.modalTitle;
- }
- }
-
- /* ── Properties ───────────────────────────────────────────── */
-
- get modalTitle(): string {
- return this.getAttribute('title') ?? '';
- }
-
- set modalTitle(v: string) {
- this.setAttribute('title', v);
- }
-
- get open(): boolean {
- return this.hasAttribute('open');
- }
-
- set open(v: boolean) {
- if (v) {
- this.setAttribute('open', '');
- } else {
- this.removeAttribute('open');
- }
- }
-
- /* ── Public ───────────────────────────────────────────────── */
-
- close(action?: string): void {
- this.dispatchEvent(
- new CustomEvent('ep-modal-close', {bubbles: true, composed: true, detail: {action}}),
- );
- this.open = false;
- }
-
- /* ── Static helpers ───────────────────────────────────────── */
-
- static confirm(options: ConfirmOptions): Promise {
- return new Promise((resolve) => {
- const modal = document.createElement('ep-modal') as EpModal;
- modal.setAttribute('title', options.title);
- modal._resolvePromise = resolve as (v: unknown) => void;
-
- // Build internal DOM (bypass slots for programmatic usage)
- const bodyContent = document.createElement('p');
- bodyContent.textContent = options.message;
- bodyContent.style.margin = '0';
- modal.appendChild(bodyContent);
-
- const actionsDiv = document.createElement('div');
- actionsDiv.setAttribute('slot', 'actions');
-
- const cancelBtn = document.createElement('button');
- cancelBtn.textContent = options.cancelText ?? 'Cancel';
- cancelBtn.setAttribute('data-action', 'cancel');
-
- const confirmBtn = document.createElement('button');
- confirmBtn.textContent = options.confirmText ?? 'Confirm';
- confirmBtn.setAttribute('data-action', 'confirm');
-
- actionsDiv.append(cancelBtn, confirmBtn);
- modal.appendChild(actionsDiv);
- document.body.appendChild(modal);
-
- // Style buttons after they render in the shadow DOM
- requestAnimationFrame(() => {
- const shadowActions = modal._shadow.querySelectorAll('.actions button');
- // No shadow buttons for slotted content; handle via action events
- });
-
- modal.addEventListener('ep-modal-action', ((e: CustomEvent) => {
- const confirmed = e.detail?.action === 'confirm';
- resolve(confirmed);
- modal.remove();
- }) as EventListener);
-
- modal.addEventListener('ep-modal-close', () => {
- resolve(false);
- modal.remove();
- });
-
- modal.open = true;
- });
- }
-
- static prompt(options: PromptOptions): Promise {
- return new Promise((resolve) => {
- const modal = document.createElement('ep-modal') as EpModal;
- modal.setAttribute('title', options.title);
- modal._resolvePromise = resolve as (v: unknown) => void;
-
- const container = document.createElement('div');
- const msg = document.createElement('p');
- msg.textContent = options.message;
- msg.style.margin = '0';
-
- const input = document.createElement('input');
- input.type = 'text';
- input.className = 'prompt-input';
- input.placeholder = options.placeholder ?? '';
-
- container.append(msg, input);
- modal.appendChild(container);
-
- const actionsDiv = document.createElement('div');
- actionsDiv.setAttribute('slot', 'actions');
-
- const cancelBtn = document.createElement('button');
- cancelBtn.textContent = 'Cancel';
- cancelBtn.setAttribute('data-action', 'cancel');
-
- const confirmBtn = document.createElement('button');
- confirmBtn.textContent = 'OK';
- confirmBtn.setAttribute('data-action', 'confirm');
-
- actionsDiv.append(cancelBtn, confirmBtn);
- modal.appendChild(actionsDiv);
- document.body.appendChild(modal);
-
- modal.addEventListener('ep-modal-action', ((e: CustomEvent) => {
- if (e.detail?.action === 'confirm') {
- // Try shadow DOM input first, then light DOM
- const shadowInput = modal._shadow.querySelector('.prompt-input');
- const lightInput = modal.querySelector('input');
- resolve(shadowInput?.value ?? lightInput?.value ?? '');
- } else {
- resolve(null);
- }
- modal.remove();
- }) as EventListener);
-
- modal.addEventListener('ep-modal-close', () => {
- resolve(null);
- modal.remove();
- });
-
- modal.open = true;
-
- // Focus the input once rendered.
- requestAnimationFrame(() => {
- const lightInput = modal.querySelector('input');
- lightInput?.focus();
- });
- });
- }
-
- /* ── Private ──────────────────────────────────────────────── */
-
- private _render(): void {
- this._shadow.innerHTML = `
-
-
-
- `;
-
- this._shadow.querySelector('.overlay')?.addEventListener('click', () => this.close());
- this._shadow.querySelector('.close-btn')?.addEventListener('click', () => this.close());
-
- // Listen for data-action clicks from slotted content
- this.addEventListener('click', (e: Event) => {
- const target = e.target;
- if (!(target instanceof HTMLElement)) return;
- const action = target.closest('[data-action]')?.dataset.action;
- if (action) {
- this.dispatchEvent(
- new CustomEvent('ep-modal-action', {bubbles: true, composed: true, detail: {action}}),
- );
- if (action === 'cancel') this.close(action);
- }
- });
- }
-
- private _onOpen(): void {
- this._previousFocus = document.activeElement instanceof HTMLElement
- ? document.activeElement
- : null;
-
- document.addEventListener('keydown', this._onKeyDown);
-
- // Focus the dialog itself
- requestAnimationFrame(() => {
- const dialog = this._shadow.querySelector('.dialog');
- dialog?.focus();
- });
- }
-
- private _onClose(): void {
- document.removeEventListener('keydown', this._onKeyDown);
- this._previousFocus?.focus();
- this._previousFocus = null;
- }
-
- private _handleKeyDown(e: KeyboardEvent): void {
- if (e.key === 'Escape') {
- e.preventDefault();
- e.stopPropagation();
- this.close();
- return;
- }
-
- if (e.key === 'Tab') {
- this._trapFocus(e);
- }
- }
-
- private _trapFocus(e: KeyboardEvent): void {
- // Collect all focusable elements in both shadow and light DOM.
- const focusable = this._getFocusableElements();
- if (focusable.length === 0) return;
-
- const first = focusable[0];
- const last = focusable[focusable.length - 1];
-
- // Determine the currently focused element, checking both shadow and light DOM.
- const active = this._shadow.activeElement ?? document.activeElement;
-
- if (e.shiftKey) {
- if (active === first || !focusable.includes(active as HTMLElement)) {
- e.preventDefault();
- last.focus();
- }
- } else {
- if (active === last || !focusable.includes(active as HTMLElement)) {
- e.preventDefault();
- first.focus();
- }
- }
- }
-
- private _getFocusableElements(): HTMLElement[] {
- const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
-
- // Shadow DOM focusable elements.
- const shadowEls = Array.from(this._shadow.querySelectorAll(selector));
- // Light DOM (slotted) focusable elements.
- const lightEls = Array.from(this.querySelectorAll(selector));
-
- return [...shadowEls, ...lightEls].filter(
- (el) => !el.hasAttribute('disabled') && el.offsetParent !== null,
- );
- }
-
- private _escapeHtml(text: string): string {
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
- }
-}
-
-customElements.define('ep-modal', EpModal);
diff --git a/ui/src/js/components/EpNotification.ts b/ui/src/js/components/EpNotification.ts
deleted file mode 100644
index c319f8af..00000000
--- a/ui/src/js/components/EpNotification.ts
+++ /dev/null
@@ -1,323 +0,0 @@
-/**
- * EpNotification — Web Component replacement for the gritter/notification system.
- *
- * Usage:
- *
- * Message text here
- *
- *
- * Static helpers:
- * EpNotification.show({ text, type, duration, position })
- * EpNotification.success(text, duration?)
- * EpNotification.error(text, duration?)
- */
-
-const notificationStyles = /* css */ `
- :host {
- --ep-bg-success: #000;
- --ep-bg-error: #dc2626;
- --ep-bg-info: #171717;
- --ep-fg: #fff;
- --ep-radius: 8px;
- --ep-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
- --ep-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
- Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
-
- display: block;
- pointer-events: auto;
- font-family: var(--ep-font);
- font-size: 14px;
- line-height: 1.5;
- max-width: 420px;
- width: 100%;
- box-sizing: border-box;
- opacity: 0;
- transform: translateY(calc(var(--ep-slide-dir, -1) * 12px));
- transition: opacity 0.25s ease, transform 0.25s ease;
- }
-
- :host([visible]) {
- opacity: 1;
- transform: translateY(0);
- }
-
- :host([removing]) {
- opacity: 0;
- transform: translateY(calc(var(--ep-slide-dir, -1) * 12px));
- transition: opacity 0.2s ease, transform 0.2s ease;
- }
-
- @media (prefers-color-scheme: light) {
- :host {
- --ep-bg-success: #000;
- --ep-bg-error: #dc2626;
- --ep-bg-info: #171717;
- --ep-fg: #fff;
- }
- }
-
- @media (prefers-color-scheme: dark) {
- :host {
- --ep-bg-success: #22c55e;
- --ep-bg-error: #ef4444;
- --ep-bg-info: #e5e5e5;
- --ep-fg: #000;
- }
- }
-
- .notification {
- display: flex;
- align-items: flex-start;
- gap: 10px;
- padding: 12px 16px;
- border-radius: var(--ep-radius);
- box-shadow: var(--ep-shadow);
- color: var(--ep-fg);
- background: var(--ep-bg-info);
- }
-
- :host([type="success"]) .notification {
- background: var(--ep-bg-success);
- }
-
- :host([type="error"]) .notification {
- background: var(--ep-bg-error);
- }
-
- .icon {
- flex-shrink: 0;
- width: 18px;
- height: 18px;
- margin-top: 1px;
- }
-
- .body {
- flex: 1;
- min-width: 0;
- word-wrap: break-word;
- }
-
- .close {
- flex-shrink: 0;
- background: none;
- border: none;
- color: inherit;
- cursor: pointer;
- padding: 0;
- margin: 0;
- opacity: 0.6;
- transition: opacity 0.15s ease;
- line-height: 1;
- font-size: 18px;
- width: 20px;
- height: 20px;
- display: flex;
- align-items: center;
- justify-content: center;
- }
-
- .close:hover,
- .close:focus-visible {
- opacity: 1;
- }
-
- .close:focus-visible {
- outline: 2px solid currentColor;
- outline-offset: 2px;
- border-radius: 2px;
- }
-`;
-
-const iconSvg: Record = {
- success: ` `,
- error: ` `,
- info: ` `,
-};
-
-type NotificationPosition = 'top' | 'bottom';
-type NotificationType = 'success' | 'error' | 'info';
-
-interface NotificationOptions {
- text: string;
- type?: NotificationType;
- duration?: number;
- position?: NotificationPosition;
-}
-
-/**
- * Container element that manages stacking of notifications at a given position.
- */
-const ensureContainer = (position: NotificationPosition): HTMLElement => {
- const id = `ep-notification-container-${position}`;
- let container = document.getElementById(id);
- if (container) return container;
-
- container = document.createElement('div');
- container.id = id;
- Object.assign(container.style, {
- position: 'fixed',
- [position === 'top' ? 'top' : 'bottom']: '16px',
- right: '16px',
- zIndex: '10000',
- display: 'flex',
- flexDirection: position === 'top' ? 'column' : 'column-reverse',
- gap: '8px',
- pointerEvents: 'none',
- maxWidth: '100vw',
- width: '420px',
- } as CSSStyleDeclaration as Record);
- document.body.appendChild(container);
- return container;
-};
-
-export class EpNotification extends HTMLElement {
- static observedAttributes = ['position', 'duration', 'type'];
-
- private _dismissTimer: ReturnType | null = null;
- private _shadow: ShadowRoot;
-
- constructor() {
- super();
- this._shadow = this.attachShadow({mode: 'open'});
- }
-
- /* ── Lifecycle ────────────────────────────────────────────── */
-
- connectedCallback(): void {
- this._render();
- this._startAutoClose();
-
- // Slide direction: -1 for top (slide down from above), +1 for bottom (slide up from below)
- const dir = this.position === 'bottom' ? '1' : '-1';
- this.style.setProperty('--ep-slide-dir', dir);
-
- // Trigger enter animation on the next frame.
- requestAnimationFrame(() => this.setAttribute('visible', ''));
- }
-
- disconnectedCallback(): void {
- this._clearTimer();
- }
-
- attributeChangedCallback(name: string, _old: string | null, _next: string | null): void {
- if (name === 'duration') {
- this._clearTimer();
- this._startAutoClose();
- }
- if (name === 'type') {
- this._render();
- }
- }
-
- /* ── Properties ───────────────────────────────────────────── */
-
- get position(): NotificationPosition {
- const val = this.getAttribute('position');
- return val === 'bottom' ? 'bottom' : 'top';
- }
-
- set position(v: NotificationPosition) {
- this.setAttribute('position', v);
- }
-
- get duration(): number {
- const val = this.getAttribute('duration');
- const parsed = val != null ? parseInt(val, 10) : NaN;
- return Number.isFinite(parsed) ? parsed : 3000;
- }
-
- set duration(v: number) {
- this.setAttribute('duration', String(v));
- }
-
- get type(): NotificationType {
- const val = this.getAttribute('type');
- if (val === 'success' || val === 'error') return val;
- return 'info';
- }
-
- set type(v: NotificationType) {
- this.setAttribute('type', v);
- }
-
- /* ── Public ───────────────────────────────────────────────── */
-
- dismiss(): void {
- this._clearTimer();
- this.removeAttribute('visible');
- this.setAttribute('removing', '');
-
- const onDone = () => {
- this.removeEventListener('transitionend', onDone);
- this.remove();
- this._cleanupEmptyContainer();
- };
- this.addEventListener('transitionend', onDone);
-
- // Safety: remove even if transitionend never fires.
- setTimeout(onDone, 350);
- }
-
- /* ── Static helpers ───────────────────────────────────────── */
-
- static show(options: NotificationOptions): EpNotification {
- const el = document.createElement('ep-notification') as EpNotification;
- el.type = options.type ?? 'info';
- el.duration = options.duration ?? 3000;
- el.position = options.position ?? 'top';
- el.textContent = options.text;
-
- const container = ensureContainer(el.position);
- container.appendChild(el);
- return el;
- }
-
- static success(text: string, duration?: number): EpNotification {
- return EpNotification.show({text, type: 'success', duration});
- }
-
- static error(text: string, duration?: number): EpNotification {
- return EpNotification.show({text, type: 'error', duration: duration ?? 5000});
- }
-
- /* ── Private ──────────────────────────────────────────────── */
-
- private _render(): void {
- const type = this.type;
- const icon = iconSvg[type] ?? iconSvg.info;
-
- this._shadow.innerHTML = `
-
-
- `;
-
- this._shadow.querySelector('.close')?.addEventListener('click', () => this.dismiss());
- }
-
- private _startAutoClose(): void {
- const d = this.duration;
- if (d > 0) {
- this._dismissTimer = setTimeout(() => this.dismiss(), d);
- }
- }
-
- private _clearTimer(): void {
- if (this._dismissTimer != null) {
- clearTimeout(this._dismissTimer);
- this._dismissTimer = null;
- }
- }
-
- private _cleanupEmptyContainer(): void {
- for (const pos of ['top', 'bottom'] as const) {
- const c = document.getElementById(`ep-notification-container-${pos}`);
- if (c && c.children.length === 0) c.remove();
- }
- }
-}
-
-customElements.define('ep-notification', EpNotification);
diff --git a/ui/src/js/components/EpToast.ts b/ui/src/js/components/EpToast.ts
deleted file mode 100644
index 5c50f194..00000000
--- a/ui/src/js/components/EpToast.ts
+++ /dev/null
@@ -1,349 +0,0 @@
-/**
- * EpToastContainer — A lightweight toast notification container.
- *
- * Usage:
- *
- *
- * API:
- * EpToastContainer.getInstance().addToast({ message, type, duration })
- */
-
-const toastContainerStyles = /* css */ `
- :host {
- --ep-toast-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
- Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
-
- position: fixed;
- z-index: 10002;
- display: flex;
- flex-direction: column;
- gap: 8px;
- pointer-events: none;
- max-width: 380px;
- width: 100%;
- font-family: var(--ep-toast-font);
- font-size: 14px;
- }
-
- /* Position variants */
- :host([position="top-right"]),
- :host(:not([position])) {
- top: 16px;
- right: 16px;
- }
-
- :host([position="top-left"]) {
- top: 16px;
- left: 16px;
- }
-
- :host([position="bottom-right"]) {
- bottom: 16px;
- right: 16px;
- flex-direction: column-reverse;
- }
-
- :host([position="bottom-left"]) {
- bottom: 16px;
- left: 16px;
- flex-direction: column-reverse;
- }
-`;
-
-const toastItemStyles = /* css */ `
- :host {
- --ep-toast-bg: #171717;
- --ep-toast-fg: #fff;
- --ep-toast-radius: 8px;
- --ep-toast-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
- --ep-toast-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
- Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
-
- display: block;
- pointer-events: auto;
- font-family: var(--ep-toast-font);
- font-size: 14px;
- line-height: 1.5;
- opacity: 0;
- transform: translateX(16px);
- transition: opacity 0.25s ease, transform 0.25s ease;
- }
-
- :host([visible]) {
- opacity: 1;
- transform: translateX(0);
- }
-
- :host([removing]) {
- opacity: 0;
- transform: translateX(16px);
- transition: opacity 0.2s ease, transform 0.2s ease;
- }
-
- /* Slide from left for left-positioned containers */
- :host([slide-from="left"]) {
- transform: translateX(-16px);
- }
-
- :host([slide-from="left"][visible]) {
- transform: translateX(0);
- }
-
- :host([slide-from="left"][removing]) {
- transform: translateX(-16px);
- }
-
- @media (prefers-color-scheme: dark) {
- :host {
- --ep-toast-bg: #262626;
- --ep-toast-fg: #e5e5e5;
- --ep-toast-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
- }
- }
-
- .toast {
- display: flex;
- align-items: flex-start;
- gap: 10px;
- padding: 12px 16px;
- border-radius: var(--ep-toast-radius);
- box-shadow: var(--ep-toast-shadow);
- background: var(--ep-toast-bg);
- color: var(--ep-toast-fg);
- }
-
- :host([type="success"]) .toast {
- border-left: 3px solid #22c55e;
- }
-
- :host([type="error"]) .toast {
- border-left: 3px solid #ef4444;
- }
-
- :host([type="info"]) .toast {
- border-left: 3px solid #3b82f6;
- }
-
- .icon {
- flex-shrink: 0;
- width: 16px;
- height: 16px;
- margin-top: 2px;
- }
-
- .message {
- flex: 1;
- min-width: 0;
- word-wrap: break-word;
- }
-
- .close {
- flex-shrink: 0;
- background: none;
- border: none;
- color: inherit;
- cursor: pointer;
- padding: 0;
- opacity: 0.5;
- transition: opacity 0.15s ease;
- font-size: 16px;
- line-height: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- }
-
- .close:hover,
- .close:focus-visible {
- opacity: 1;
- }
-
- .close:focus-visible {
- outline: 2px solid currentColor;
- outline-offset: 2px;
- border-radius: 2px;
- }
-
- .progress {
- position: absolute;
- bottom: 0;
- left: 0;
- height: 2px;
- background: rgba(255, 255, 255, 0.3);
- border-radius: 0 0 var(--ep-toast-radius) var(--ep-toast-radius);
- transition: width linear;
- }
-`;
-
-type ToastPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
-type ToastType = 'success' | 'error' | 'info';
-
-interface ToastOptions {
- message: string;
- type?: ToastType;
- duration?: number;
-}
-
-const toastIconSvg: Record = {
- success: ` `,
- error: ` `,
- info: ` `,
-};
-
-/* ── Internal Toast Item ───────────────────────────────────── */
-
-class EpToastItem extends HTMLElement {
- private _shadow: ShadowRoot;
- private _dismissTimer: ReturnType | null = null;
-
- constructor() {
- super();
- this._shadow = this.attachShadow({mode: 'open'});
- }
-
- connectedCallback(): void {
- const type = this.getAttribute('type') ?? 'info';
- const message = this.getAttribute('message') ?? '';
- const icon = toastIconSvg[type] ?? toastIconSvg.info;
-
- this._shadow.innerHTML = `
-
-
- ${icon}
- ${this._escapeHtml(message)}
- ×
-
- `;
-
- this._shadow.querySelector('.close')?.addEventListener('click', () => this.dismiss());
-
- // Slide animation entrance.
- requestAnimationFrame(() => this.setAttribute('visible', ''));
-
- // Auto-dismiss.
- const duration = parseInt(this.getAttribute('duration') ?? '4000', 10);
- if (duration > 0) {
- this._dismissTimer = setTimeout(() => this.dismiss(), duration);
- }
- }
-
- disconnectedCallback(): void {
- if (this._dismissTimer != null) {
- clearTimeout(this._dismissTimer);
- this._dismissTimer = null;
- }
- }
-
- dismiss(): void {
- if (this._dismissTimer != null) {
- clearTimeout(this._dismissTimer);
- this._dismissTimer = null;
- }
- this.removeAttribute('visible');
- this.setAttribute('removing', '');
-
- const cleanup = () => {
- this.removeEventListener('transitionend', cleanup);
- this.remove();
- };
- this.addEventListener('transitionend', cleanup);
- setTimeout(cleanup, 300);
- }
-
- private _escapeHtml(text: string): string {
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
- }
-}
-
-// Register the toast item element.
-customElements.define('ep-toast-item', EpToastItem);
-
-/* ── Toast Container ───────────────────────────────────────── */
-
-const MAX_VISIBLE = 5;
-
-export class EpToastContainer extends HTMLElement {
- static observedAttributes = ['position'];
-
- private _shadow: ShadowRoot;
-
- private static _instance: EpToastContainer | null = null;
-
- constructor() {
- super();
- this._shadow = this.attachShadow({mode: 'open'});
- }
-
- /* ── Lifecycle ────────────────────────────────────────────── */
-
- connectedCallback(): void {
- this._shadow.innerHTML = `
-
-
- `;
-
- // Register as singleton instance.
- EpToastContainer._instance = this;
- }
-
- disconnectedCallback(): void {
- if (EpToastContainer._instance === this) {
- EpToastContainer._instance = null;
- }
- }
-
- /* ── Properties ───────────────────────────────────────────── */
-
- get position(): ToastPosition {
- const val = this.getAttribute('position');
- if (val === 'top-left' || val === 'bottom-right' || val === 'bottom-left') return val;
- return 'top-right';
- }
-
- set position(v: ToastPosition) {
- this.setAttribute('position', v);
- }
-
- /* ── Static accessor ──────────────────────────────────────── */
-
- /**
- * Returns the singleton toast container, creating one if it does not exist.
- */
- static getInstance(): EpToastContainer {
- if (EpToastContainer._instance) return EpToastContainer._instance;
-
- const container = document.createElement('ep-toast-container') as EpToastContainer;
- container.setAttribute('position', 'top-right');
- document.body.appendChild(container);
- return container;
- }
-
- /* ── Public API ───────────────────────────────────────────── */
-
- addToast(options: ToastOptions): EpToastItem {
- // Enforce max visible limit — remove oldest if necessary.
- const existing = this.querySelectorAll('ep-toast-item');
- if (existing.length >= MAX_VISIBLE) {
- const oldest = existing[0] as EpToastItem;
- oldest.dismiss();
- }
-
- const toast = document.createElement('ep-toast-item') as EpToastItem;
- toast.setAttribute('message', options.message);
- toast.setAttribute('type', options.type ?? 'info');
- toast.setAttribute('duration', String(options.duration ?? 4000));
-
- // Set slide direction based on container position.
- const isLeft = this.position.includes('left');
- if (isLeft) {
- toast.setAttribute('slide-from', 'left');
- }
-
- this.appendChild(toast);
- return toast;
- }
-}
-
-customElements.define('ep-toast-container', EpToastContainer);
diff --git a/ui/src/js/components/EpToolbarSelect.d.ts b/ui/src/js/components/EpToolbarSelect.d.ts
deleted file mode 100644
index 7f8fb468..00000000
--- a/ui/src/js/components/EpToolbarSelect.d.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export interface ToolbarSelectOption {
- label: string;
- value: string;
-}
-
-export interface EpToolbarSelectElement extends HTMLElement {
- options: ToolbarSelectOption[];
- value: string;
-}
diff --git a/ui/src/js/components/EpToolbarSelect.ts b/ui/src/js/components/EpToolbarSelect.ts
deleted file mode 100644
index eb3b37c4..00000000
--- a/ui/src/js/components/EpToolbarSelect.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-type ToolbarSelectOption = {
- label: string;
- value: string;
-};
-
-const STYLE_ID = 'ep-toolbar-select-styles';
-
-const ensureStyles = () => {
- if (document.getElementById(STYLE_ID)) return;
- const style = document.createElement('style');
- style.id = STYLE_ID;
- style.textContent = `
- ep-toolbar-select {
- display: flex;
- align-items: center;
- min-width: 0;
- }
-
- ep-toolbar-select ep-dropdown {
- display: flex;
- align-items: center;
- }
-
- ep-toolbar-select .ep-toolbar-select__button {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- min-width: 28px;
- height: 28px;
- padding: 0 8px;
- border: none;
- border-radius: 3px;
- background: transparent;
- color: inherit;
- cursor: pointer;
- white-space: nowrap;
- font: inherit;
- }
-
- ep-toolbar-select .ep-toolbar-select__button:hover {
- background-color: #f2f3f4;
- background-color: var(--bg-soft-color, #f2f3f4);
- color: #485365;
- color: var(--text-color, #485365);
- }
-
- ep-toolbar-select .ep-toolbar-select__button:focus-visible {
- outline: 2px solid #64d29b;
- outline-offset: 1px;
- }
-
- ep-toolbar-select .ep-toolbar-select__icon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- flex: 0 0 auto;
- }
-
- ep-toolbar-select .ep-toolbar-select__text {
- display: inline-block;
- min-width: 0;
- max-width: 96px;
- overflow: hidden;
- text-overflow: ellipsis;
- font-size: 12px;
- font-weight: 500;
- }
-
- ep-toolbar-select .ep-toolbar-select__caret {
- flex: 0 0 auto;
- display: inline-block;
- width: 8px;
- height: 8px;
- border-right: 2px solid currentColor;
- border-bottom: 2px solid currentColor;
- transform: translateY(-1px) rotate(45deg);
- opacity: 0.7;
- }
- `;
- document.head.appendChild(style);
-};
-
-export class EpToolbarSelect extends HTMLElement {
- private _options: ToolbarSelectOption[] = [];
- private _value = '';
- private _button?: HTMLButtonElement;
- private _label?: HTMLSpanElement;
-
- connectedCallback(): void {
- ensureStyles();
- this._render();
- }
-
- get options(): ToolbarSelectOption[] {
- return this._options;
- }
-
- set options(options: ToolbarSelectOption[]) {
- this._options = Array.isArray(options) ? options : [];
- this._render();
- }
-
- get value(): string {
- return this._value;
- }
-
- set value(value: string) {
- this._value = value ?? '';
- this._updateTrigger();
- }
-
- private _render(): void {
- this.replaceChildren();
-
- const dropdown = document.createElement('ep-dropdown');
- dropdown.setAttribute('align', 'left');
- dropdown.setAttribute('trigger', 'click');
-
- const button = document.createElement('button');
- button.type = 'button';
- button.slot = 'trigger';
- button.className = 'ep-toolbar-select__button';
-
- const iconClass = this.getAttribute('icon-class');
- if (iconClass) {
- const icon = document.createElement('span');
- icon.className = `buttonicon ${iconClass} ep-toolbar-select__icon`;
- button.appendChild(icon);
- }
-
- const label = document.createElement('span');
- label.className = 'ep-toolbar-select__text';
- button.appendChild(label);
-
- const caret = document.createElement('span');
- caret.className = 'ep-toolbar-select__caret';
- caret.setAttribute('aria-hidden', 'true');
- button.appendChild(caret);
-
- const content = document.createElement('div');
- content.slot = 'content';
- for (const option of this._options) {
- const item = document.createElement('ep-dropdown-item');
- item.setAttribute('value', option.value);
- item.textContent = option.label;
- content.appendChild(item);
- }
-
- dropdown.addEventListener('ep-dropdown-select', ((event: CustomEvent) => {
- this._value = String(event.detail?.value ?? '');
- this._updateTrigger();
- this.dispatchEvent(new CustomEvent('ep-toolbar-select:change', {
- bubbles: true,
- composed: true,
- detail: {value: this._value},
- }));
- }) as EventListener);
-
- dropdown.append(button, content);
- this.appendChild(dropdown);
-
- this._button = button;
- this._label = label;
- this._updateTrigger();
- }
-
- private _updateTrigger(): void {
- if (!this._button || !this._label) return;
- const selected = this._options.find((option) => option.value === this._value);
- const visibleLabel = selected?.label ?? this.getAttribute('placeholder') ?? this.getAttribute('label') ?? '';
- const titlePrefix = this.getAttribute('label') ?? '';
-
- this._label.textContent = visibleLabel;
- this._button.title = titlePrefix && selected ? `${titlePrefix}: ${selected.label}` : (titlePrefix || visibleLabel);
- this._button.setAttribute('aria-label', this._button.title);
- }
-}
-
-customElements.define('ep-toolbar-select', EpToolbarSelect);
diff --git a/ui/src/js/components/index.ts b/ui/src/js/components/index.ts
index 7a648a01..0047e8f5 100644
--- a/ui/src/js/components/index.ts
+++ b/ui/src/js/components/index.ts
@@ -2,22 +2,23 @@
* Etherpad UI Web Components
*
* Import this module to register all custom elements.
- * Components are self-contained and work without the EventBus — they will be
- * wired together with core/EventBus and core/BaseComponent later.
+ * Most components come from the etherpad-webcomponents npm package.
+ * EpPluginToolbar is local-only (not in the npm package).
*/
-import './EpNotification';
-import './EpModal';
-import './EpToast';
-import './EpColorPicker';
-import './EpDropdown';
+import 'etherpad-webcomponents/EpNotification.js';
+import 'etherpad-webcomponents/EpModal.js';
+import 'etherpad-webcomponents/EpToast.js';
+import 'etherpad-webcomponents/EpColorPicker.js';
+import 'etherpad-webcomponents/EpDropdown.js';
+import 'etherpad-webcomponents/EpToolbarSelect.js';
+import 'etherpad-webcomponents/EpCheckbox.js';
import './EpPluginToolbar';
-import './EpToolbarSelect';
-export {EpNotification} from './EpNotification';
-export {EpModal} from './EpModal';
-export {EpToastContainer} from './EpToast';
-export {EpColorPicker} from './EpColorPicker';
-export {EpDropdown, EpDropdownItem} from './EpDropdown';
+export {EpNotification} from 'etherpad-webcomponents';
+export {EpModal} from 'etherpad-webcomponents';
+export {EpToastContainer} from 'etherpad-webcomponents';
+export {EpColorPicker} from 'etherpad-webcomponents';
+export {EpDropdown, EpDropdownItem} from 'etherpad-webcomponents';
export {EpPluginToolbar} from './EpPluginToolbar';
-export {EpToolbarSelect} from './EpToolbarSelect';
+export {EpToolbarSelect} from 'etherpad-webcomponents';
diff --git a/ui/src/js/core/ComponentBridge.ts b/ui/src/js/core/ComponentBridge.ts
index 421affd1..25684da6 100644
--- a/ui/src/js/core/ComponentBridge.ts
+++ b/ui/src/js/core/ComponentBridge.ts
@@ -1,7 +1,5 @@
import { editorBus } from './EventBus'
-import { EpNotification } from '../components/EpNotification'
-import { EpModal } from '../components/EpModal'
-import { EpToastContainer } from '../components/EpToast'
+import { EpNotification, EpToastContainer } from 'etherpad-webcomponents'
// Initialize toast container
const toasts = EpToastContainer.getInstance()
diff --git a/ui/src/js/core/EventBus.ts b/ui/src/js/core/EventBus.ts
index 4a45caf0..4bea0847 100644
--- a/ui/src/js/core/EventBus.ts
+++ b/ui/src/js/core/EventBus.ts
@@ -304,4 +304,12 @@ export class EventBus = EditorEvents> {
// Singleton instance for the editor
// ---------------------------------------------------------------------------
-export const editorBus = new EventBus();
+// Uses a global reference so that when bundled with etherpad-webcomponents,
+// both packages share the exact same EventBus instance. This is critical for
+// plugin hooks (editor:attribs:to:classes, editor:process:line:attribs, etc.)
+// to work across package boundaries.
+const _global = globalThis as any;
+if (!_global.__etherpadEditorBus) {
+ _global.__etherpadEditorBus = new EventBus();
+}
+export const editorBus: EventBus = _global.__etherpadEditorBus;
diff --git a/ui/src/js/notifications.ts b/ui/src/js/notifications.ts
index b81cec56..38f30886 100644
--- a/ui/src/js/notifications.ts
+++ b/ui/src/js/notifications.ts
@@ -1,8 +1,8 @@
-import './components/EpNotification'
-import { EpNotification } from './components/EpNotification'
+import 'etherpad-webcomponents/EpNotification.js'
+import { EpNotification } from 'etherpad-webcomponents'
export const notifications = {
- add(args: { title?: string; text: string | Node; class_name?: string; sticky?: boolean; time?: number; position?: 'top' | 'bottom' }): string {
+ add(args: { title?: string; text: string | Node; class_name?: string; sticky?: boolean; time?: number; position?: 'top' | 'bottom' }): EpNotification {
const type = args.class_name?.includes('error') ? 'error' : 'success'
const textContent = args.text instanceof Node ? (args.text as HTMLElement).textContent || '' : String(args.text)
return EpNotification.show({
diff --git a/ui/src/js/pad.ts b/ui/src/js/pad.ts
index 6cded011..dd134d35 100644
--- a/ui/src/js/pad.ts
+++ b/ui/src/js/pad.ts
@@ -61,6 +61,8 @@ const hideById = (id: string) => setDisplay(id, 'none');
const showById = (id: string, display = 'block') => setDisplay(id, display);
const setCheckedById = (id: string, value: boolean) => {
const el = byId(id);
+ if (!el) return;
+ if (el.tagName === 'EP-CHECKBOX') { (el as any).checked = value; return; }
if (el instanceof HTMLInputElement) el.checked = value;
};
@@ -497,7 +499,8 @@ const pad = {
setTimeout(() => {
padeditor.ace.focus();
}, 0);
- byId('options-stickychat')?.addEventListener('click', () => chat.stickToScreen());
+ byId('options-stickychat')?.addEventListener('ep-change', () => chat.stickToScreen());
+ byId('options-chatandusers')?.addEventListener('ep-change', () => chat.chatAndUsers());
if (padcookie.getPref('chatAlwaysVisible')) {
chat.stickToScreen(true);
setCheckedById('options-stickychat', true);
@@ -527,13 +530,15 @@ const pad = {
const checkChatAndUsersVisibility = (x) => {
if (x.matches) {
- const chatAndUsers = byId('options-chatandusers');
- if (chatAndUsers instanceof HTMLInputElement && chatAndUsers.checked) {
- chatAndUsers.click();
+ const chatAndUsers = byId('options-chatandusers') as any;
+ if (chatAndUsers?.checked) {
+ chatAndUsers.checked = false;
+ chatAndUsers.dispatchEvent(new CustomEvent('ep-change', {detail: {checked: false}}));
}
- const stickyChat = byId('options-stickychat');
- if (stickyChat instanceof HTMLInputElement && stickyChat.checked) {
- stickyChat.click();
+ const stickyChat = byId('options-stickychat') as any;
+ if (stickyChat?.checked) {
+ stickyChat.checked = false;
+ stickyChat.dispatchEvent(new CustomEvent('ep-change', {detail: {checked: false}}));
}
}
};
diff --git a/ui/src/js/pad_editbar.ts b/ui/src/js/pad_editbar.ts
index 6ac4b8e0..f7c69113 100644
--- a/ui/src/js/pad_editbar.ts
+++ b/ui/src/js/pad_editbar.ts
@@ -330,9 +330,12 @@ export const padeditbar = new class {
const qrImage = q('#qrcodeimg');
const qrLinkInput = q('#qrcodelinkinput');
if (!(qrImage instanceof HTMLImageElement) || !(qrLinkInput instanceof HTMLInputElement)) return;
- const {link} = this.getShareLinks(Boolean(readonlyInput instanceof HTMLInputElement && readonlyInput.checked));
+ // #qrreadonlyinput is an , not a native . Reading .checked
+ // works on both because the Lit component exposes a reflected `checked` property.
+ const isReadonly = Boolean((readonlyInput as any)?.checked);
+ const {link} = this.getShareLinks(isReadonly);
qrLinkInput.value = link;
- qrImage.src = this.getQrCodeSrc(Boolean(readonlyInput instanceof HTMLInputElement && readonlyInput.checked));
+ qrImage.src = this.getQrCodeSrc(isReadonly);
}
_syncToolbarScrollState() {
diff --git a/ui/src/js/pad_editor.ts b/ui/src/js/pad_editor.ts
index 603b0373..dfd415da 100644
--- a/ui/src/js/pad_editor.ts
+++ b/ui/src/js/pad_editor.ts
@@ -44,14 +44,11 @@ export const padeditor = (() => {
settings = pad.settings;
self.ace = new Ace2Editor();
await self.ace.init('editorcontainer', '');
- // EventBus: emit editor:ace:initialized after the ACE editor is created
- editorBus.emit('editor:ace:initialized', {editorInfo: self.ace});
+ // editor:ace:initialized is emitted by ace.ts with the shared info object
const editorLoading = q('#editorloadingbox');
if (editorLoading) editorLoading.style.display = 'none';
- // Listen for clicks on sidediv items
- const outerFrame = q('iframe[name="ace_outer"]');
- const outerDoc = outerFrame?.contentDocument;
- const sideDivInner = outerDoc?.querySelector('#sidedivinner');
+ // Listen for clicks on sidediv items (now in main document, not iframe)
+ const sideDivInner = q('#sidedivinner');
sideDivInner?.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement) || target.tagName.toLowerCase() !== 'div') return;
@@ -91,10 +88,13 @@ export const padeditor = (() => {
// font family change
- q('#viewfontmenu')?.addEventListener('change', () => {
- const menu = q('#viewfontmenu');
- pad.changeViewOption('padFontFamily', menu?.value);
- });
+ q('#viewfontmenu')?.addEventListener('ep-dropdown-select', ((e: CustomEvent) => {
+ const font = e.detail?.value ?? '';
+ // Update the trigger button text
+ const trigger = q('#viewfontmenu [slot="trigger"]');
+ if (trigger) trigger.textContent = font || html10n.get('pad.settings.fontType.normal');
+ pad.changeViewOption('padFontFamily', font);
+ }) as EventListener);
// delete pad
q('#delete-pad')?.addEventListener('click', () => {
@@ -118,14 +118,13 @@ export const padeditor = (() => {
// Language
html10n.bind('localized', () => {
- const menu = q('#languagemenu');
- if (menu) menu.value = html10n.getLanguage();
- // translate the value of 'unnamed' and 'Enter your name' textboxes in the userlist
+ // Update the language trigger button text
+ const lang = html10n.getLanguage();
+ const langItem = q(`#languagemenu ep-dropdown-item[value="${lang}"]`);
+ const trigger = q('#languagemenu [slot="trigger"]');
+ if (trigger && langItem) trigger.textContent = langItem.textContent;
- // this does not interfere with html10n's normal value-setting because
- // html10n just ingores s
- // also, a value which has been set by the user will be not overwritten
- // since a user-edited does *not* have the editempty-class
+ // translate the value of 'unnamed' and 'Enter your name' textboxes in the userlist
qa('input[data-l10n-id]').forEach((input) => {
if (!(input instanceof HTMLInputElement)) return;
if (input.classList.contains('editempty')) {
@@ -134,14 +133,18 @@ export const padeditor = (() => {
}
});
});
- const languageMenu = q('#languagemenu');
- if (languageMenu) languageMenu.value = html10n.getLanguage();
- languageMenu?.addEventListener('change', () => {
- const value = languageMenu.value;
+ // Set initial language trigger text
+ const langTrigger = q('#languagemenu [slot="trigger"]');
+ const currentLang = html10n.getLanguage();
+ const currentLangItem = q(`#languagemenu ep-dropdown-item[value="${currentLang}"]`);
+ if (langTrigger && currentLangItem) langTrigger.textContent = currentLangItem.textContent;
+
+ q('#languagemenu')?.addEventListener('ep-dropdown-select', ((e: CustomEvent) => {
+ const value = e.detail?.value ?? '';
Cookies.set('language', value, { expires: 36500 });
location.reload();
html10n.localize([value, 'en']);
- });
+ }) as EventListener);
},
setViewOptions: (newOptions) => {
const getOption = (key, defaultValue) => {
@@ -155,16 +158,27 @@ export const padeditor = (() => {
v = getOption('rtlIsTrue', ('rtl' === html10n.getDirection()));
self.ace.setProperty('rtlIsTrue', v);
+ // setProperty on the AceEditor only flips the editor body's direction.
+ // Etherpad's layout also depends on the dir attribute (the whole
+ // page flips), so mirror the setting here. The original ace2_inner.ts did
+ // this inside the editor; in the webcomponent port the editor is scoped
+ // to its own DOM, so the page-level update lives at the consumer layer.
+ document.documentElement.dir = v ? 'rtl' : 'ltr';
padutils.setCheckbox('#options-rtlcheck', v);
v = getOption('showLineNumbers', true);
self.ace.setProperty('showslinenumbers', v);
+ // #sidediv is outside the editor's scope (it's a sibling in
+ // #outerdocbody), so the webcomponent AceEditor cannot toggle its
+ // parent's class the way the original ace2_inner did.
+ document.getElementById('sidediv')
+ ?.parentElement?.classList.toggle('line-numbers-hidden', !v);
padutils.setCheckbox('#options-linenoscheck', v);
v = getOption('showAuthorColors', true);
self.ace.setProperty('showsauthorcolors', v);
q('#chattext')?.classList.toggle('authorColors', v);
- const sideDivInner = q('iframe[name="ace_outer"]')?.contentDocument?.querySelector('#sidedivinner');
+ const sideDivInner = q('#sidedivinner');
sideDivInner?.classList.toggle('authorColors', v);
padutils.setCheckbox('#options-colorscheck', v);
@@ -206,23 +220,16 @@ export const focusOnLine = (ace) => {
if (lineNumber[0] === 'L') {
const lineNumberInt = parseInt(lineNumber.substr(1));
if (lineNumberInt) {
- const outerFrame = q('iframe[name="ace_outer"]');
- const outerDoc = outerFrame?.contentDocument;
- const outerDocBody = outerDoc?.querySelector('#outerdocbody');
- const innerFrame = outerDoc?.querySelector('iframe');
- const innerDocBody = innerFrame?.contentDocument?.querySelector('#innerdocbody');
+ const innerDocBody = document.getElementById('innerdocbody');
const line = innerDocBody?.querySelector(`div:nth-child(${lineNumberInt})`);
- if (line && outerDocBody && innerDocBody) {
- let offsetTop = line.getBoundingClientRect().top - innerDocBody.getBoundingClientRect().top;
- offsetTop += parseInt(getComputedStyle(outerDocBody).paddingTop.replace('px', ''));
- const hasMobileLayout = window.matchMedia('(max-width: 1000px)').matches;
- if (!hasMobileLayout) {
- offsetTop += parseInt(getComputedStyle(innerDocBody).paddingTop.replace('px', ''));
+ if (line && innerDocBody) {
+ const offsetTop = line.getBoundingClientRect().top - innerDocBody.getBoundingClientRect().top;
+ const editorContainer = document.getElementById('editorcontainer');
+ if (editorContainer) {
+ editorContainer.scrollTop = offsetTop;
}
- (outerDocBody).style.top = `${offsetTop}px`; // Chrome
- outerDoc?.documentElement?.scrollTo({top: offsetTop}); // needed for FF
- const node = line;
ace.callWithAce((ace) => {
+ const node = line;
const selection = {
startPoint: {
index: 0,
diff --git a/ui/src/js/pad_userlist.ts b/ui/src/js/pad_userlist.ts
index 3dcb66cc..411cc368 100644
--- a/ui/src/js/pad_userlist.ts
+++ b/ui/src/js/pad_userlist.ts
@@ -18,6 +18,7 @@ import padutils from './pad_utils';
import {editorBus} from './core';
import html10n from './i18n';
import {pad} from "./pad.ts";
+import 'etherpad-webcomponents/EpUserBadge.js';
let myUserInfo: Record = {};
@@ -118,7 +119,7 @@ export const paduserlist = (() => {
const {scheduleAnimation} =
padutils.makeAnimationScheduler(animateStep, ANIMATION_STEP_TIME, LOWER_FRAMERATE_FACTOR);
- const NUMCOLS = 4;
+ const NUMCOLS = 3;
const setTdHeight = (tr: HTMLElement, height: number) => {
tr.querySelectorAll('td').forEach((td) => {
@@ -144,23 +145,21 @@ export const paduserlist = (() => {
const replaceUserRowContents = (tr: HTMLElement, height: number, data: any) => {
const tds = createUserRowTds(height, data);
- if (isNameEditable(data) && tr.querySelector('td.usertdname input:enabled')) {
- // preserve input field node
- tds.forEach((td, i) => {
- const oldTd = tr.querySelectorAll('td')[i];
- if (!oldTd?.classList.contains('usertdname')) oldTd?.replaceWith(td);
- });
- } else {
- tr.innerHTML = '';
- tr.append(...tds);
- }
+ tr.innerHTML = '';
+ tr.append(...tds);
return tr;
};
const createUserRowTds = (height: number, data: any): HTMLElement[] => {
- let name: Node;
+ const tdBadge = document.createElement('td');
+ tdBadge.style.height = `${height}px`;
+ tdBadge.className = 'usertdswatch';
+ tdBadge.colSpan = 2;
+ const badge = document.createElement('ep-user-badge') as any;
+ badge.setAttribute('color', padutils.escapeHtml(data.color));
+ badge.setAttribute('online', '');
if (data.name) {
- name = document.createTextNode(data.name);
+ badge.setAttribute('name', data.name);
} else {
const input = document.createElement('input');
input.setAttribute('data-l10n-id', 'pad.userlist.unnamed');
@@ -168,29 +167,18 @@ export const paduserlist = (() => {
input.classList.add('editempty', 'newinput');
input.value = html10n.get('pad.userlist.unnamed');
if (isNameEditable(data)) input.disabled = true;
- name = input;
+ badge.setAttribute('name', input.value);
+ tdBadge.appendChild(input);
+ input.style.display = 'none';
}
-
- const tdSwatch = document.createElement('td');
- tdSwatch.style.height = `${height}px`;
- tdSwatch.className = 'usertdswatch';
- const swatch = document.createElement('div');
- swatch.className = 'swatch';
- swatch.style.background = padutils.escapeHtml(data.color);
- swatch.innerHTML = ' ';
- tdSwatch.appendChild(swatch);
-
- const tdName = document.createElement('td');
- tdName.style.height = `${height}px`;
- tdName.className = 'usertdname';
- tdName.append(name);
+ tdBadge.prepend(badge);
const tdActivity = document.createElement('td');
tdActivity.style.height = `${height}px`;
tdActivity.className = 'activity';
tdActivity.textContent = data.activity;
- return [tdSwatch, tdName, tdActivity];
+ return [tdBadge, tdActivity];
};
const createRow = (id: string, contents: HTMLElement[], authorId: string): HTMLElement => {
diff --git a/ui/src/js/pad_utils.ts b/ui/src/js/pad_utils.ts
index dfd3a1e1..2061e10a 100644
--- a/ui/src/js/pad_utils.ts
+++ b/ui/src/js/pad_utils.ts
@@ -342,37 +342,25 @@ class PadUtils {
return {clear: () => {}};
}
getCheckbox = (node: HTMLElement | string) => {
- if (typeof node === 'string') {
- const el = document.querySelector(node);
- return el instanceof HTMLInputElement ? el.checked : false;
- }
- if (node instanceof HTMLElement) {
- return node instanceof HTMLInputElement ? node.checked : false;
- }
- return false;
+ const el = typeof node === 'string' ? document.querySelector(node) : node;
+ if (!el) return false;
+ if (el.tagName === 'EP-CHECKBOX') return (el as any).checked ?? false;
+ return el instanceof HTMLInputElement ? el.checked : false;
}
setCheckbox =
(node: HTMLElement | string, value: boolean) => {
- if (typeof node === 'string') {
- const el = document.querySelector(node);
- if (el instanceof HTMLInputElement) el.checked = value;
- return;
- }
- if (node instanceof HTMLElement) {
- if (node instanceof HTMLInputElement) node.checked = value;
- return;
- }
+ const el = typeof node === 'string' ? document.querySelector(node) : node;
+ if (!el) return;
+ if (el.tagName === 'EP-CHECKBOX') { (el as any).checked = value; return; }
+ if (el instanceof HTMLInputElement) el.checked = value;
}
bindCheckboxChange =
(node: HTMLElement | string, func: Function) => {
- if (typeof node === 'string') {
- document.querySelector(node)?.addEventListener('change', () => func());
- return;
- }
- if (node instanceof HTMLElement) {
- node.addEventListener('change', () => func());
- return;
- }
+ const el = typeof node === 'string' ? document.querySelector(node) : node;
+ if (!el) return;
+ // ep-checkbox fires 'ep-change', native checkbox fires 'change'
+ const event = el.tagName === 'EP-CHECKBOX' ? 'ep-change' : 'change';
+ el.addEventListener(event, () => func());
}
encodeUserId =
(userId: string) => userId.replace(/[^a-y0-9]/g, (c) => {