Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.0] — 2026-07-08

### Added

- **Split view (two editor panes)** — a faithful Notepad++ two-view layout.
`View → Split Horizontal` stacks a second editor pane below; `Split Vertical`
places it side-by-side. Each pane has its own tab strip, and every open
document belongs to exactly one pane. Right-click a tab → **Move to Other
View** to relocate it. The pane you last clicked is the focused pane that
Find/Replace, the status bar, macros, the Lua Console, and menu commands act
on. Choosing Split again collapses back to a single pane, as does closing the
last tab in the secondary pane. The split layout — which files are in which
pane, the orientation, and each pane's active tab — is restored on reload.

### Fixed

- **New tabs focus the editor.** Opening a tab via the `+` button or
`File → New` now places the caret in the text area so you can type
immediately, without clicking first.
- **Switching tabs focuses the editor.** Activating an existing tab (including
from the `>>` overflow dropdown) focuses its editor pane.
- **Reload focuses the editor.** After a page reload, the active document's
editor is focused so you can keep typing right away.

## [0.2.0] — 2026-07-03

### Changed
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ powered by **CodeMirror 6** and **Wasmoon** (Lua 5.4 in WASM).
![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)
[![Chrome Web Store](https://img.shields.io/chrome-web-store/v/jfhgpoliojbbmiknmdbefeamimlekgdn?label=Chrome%20Web%20Store&logo=googlechrome&logoColor=white&color=success)](https://chromewebstore.google.com/detail/notepad-web/jfhgpoliojbbmiknmdbefeamimlekgdn)
[![users](https://img.shields.io/chrome-web-store/users/jfhgpoliojbbmiknmdbefeamimlekgdn?label=users)](https://chromewebstore.google.com/detail/notepad-web/jfhgpoliojbbmiknmdbefeamimlekgdn)
![version](https://img.shields.io/badge/version-0.2.0-informational)
![version](https://img.shields.io/badge/version-0.3.0-informational)

<p align="center">
<a href="https://chromewebstore.google.com/detail/notepad-web/jfhgpoliojbbmiknmdbefeamimlekgdn"><b>⬇&nbsp; Install from the Chrome Web Store</b></a>
Expand Down Expand Up @@ -136,7 +136,7 @@ npm run package # manifest-compliance + no-remote-code checks, then zips dist

The `npm run package` step verifies that no CDN/remote URLs survived the build and that
the manifest declares only allowed permissions before producing
`notepad-web-v0.2.0.zip`.
`notepad-web-v0.3.0.zip`.

## Architecture

Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Notepad Web",
"version": "0.2.0",
"version": "0.3.0",
"description": "A CodeMirror-based code editor that runs fully offline in your browser.",
"permissions": ["storage", "contextMenus", "activeTab", "scripting"],
"background": { "service_worker": "background.js", "type": "module" },
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "notepad-web",
"version": "0.2.0",
"version": "0.3.0",
"description": "A Monaco-based code editor as a Chrome MV3 extension.",
"license": "GPL-3.0-or-later",
"type": "module",
Expand Down
14 changes: 13 additions & 1 deletion src/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ export class App {
store.setActiveForView(viewId, id);
this.controllerFor(viewId)?.showDoc(id);
this.deps.dockManager?.focusEditorGroup(viewId);
this.viewFor(viewId)?.focus();
},
(id) => {
const doc = store.get(id);
Expand All @@ -230,6 +231,7 @@ export class App {
this.applyFocus(viewId);
const d = store.create(); // create() adds to the focused view (== viewId)
this.controllerFor(viewId)?.showDoc(d.id);
this.viewFor(viewId)?.focus();
},
{
onSave: () => void fileActionsRef.current?.saveActive(),
Expand Down Expand Up @@ -297,7 +299,7 @@ export class App {
const srcController = this.controllerFor(source);
const tgtController = this.controllerFor(target);
if (!tgtController) return;
store.moveToView(id, target); // focus=target, active[target]=id, source active re-pointed
store.moveToView(id, target);
srcController?.closeDoc(id);
const srcActive = store.activeForView(source);
if (srcActive) {
Expand Down Expand Up @@ -655,6 +657,7 @@ export class App {
const doNew = (): void => {
const d = this.deps.store.create();
this.controller.showDoc(d.id);
this.view.focus();
};

const doClose = (): void => {
Expand Down Expand Up @@ -1550,4 +1553,13 @@ export class App {
if (a) controller.showDoc(a.id);
view.requestMeasure();
}

/**
* Focus the active editor pane. Called by the bootstrap once the dock is
* initialised (the #editor element has been moved into the dock group by then),
* so after a page reload the user can keep typing without clicking first.
*/
focusActiveEditor(): void {
this.view.focus();
}
}
2 changes: 2 additions & 0 deletions src/editor-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,8 @@ window.__appReady = (async () => {

// Settle layout with a rAF.
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));

app.focusActiveEditor();
})();

// ── PWA service worker (installability + offline) ────────────────────────────
Expand Down
68 changes: 68 additions & 0 deletions tests/e2e/new-tab-focus.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/**
* E2E: opening a new tab focuses the editor so the user can type immediately,
* without first clicking into the text area.
*/
import { test, expect } from '@playwright/test';

test.describe('New tab focus', () => {
const inEditor = (page: Parameters<Parameters<typeof test>[1]>[0]['page']) =>
page.evaluate(() => !!document.activeElement?.closest('.cm-editor'));

test.beforeEach(async ({ page }) => {
page.on('dialog', (d) => void d.accept());
await page.goto('/editor.html');
await page.waitForFunction(
() => (window as unknown as { __appReady?: unknown }).__appReady !== undefined,
);
await page.evaluate(() => (window as unknown as { __appReady: Promise<void> }).__appReady);
});

test('the + button focuses the editor and accepts typing right away', async ({ page }) => {
await page.locator('#tab-new').click();
await expect.poll(() => inEditor(page)).toBe(true);
// Type without clicking into the editor first.
await page.keyboard.type('HELLO');
expect(
await page.evaluate(() =>
(window as unknown as { __editor: { getValue(): string } }).__editor.getValue(),
),
).toBe('HELLO');
});

test('File → New focuses the editor', async ({ page }) => {
await page.getByRole('menuitem', { name: 'File' }).click();
await page.getByRole('menuitem', { name: 'New' }).first().click();
await expect.poll(() => inEditor(page)).toBe(true);
});

test('switching to an existing tab focuses the editor', async ({ page }) => {
// Open a second tab, then click back to the first tab.
await page.locator('#tab-new').click();
const firstTab = page.locator('#tabbar .tab').first();
await firstTab.click();
await expect.poll(() => inEditor(page)).toBe(true);
await page.keyboard.type('X');
expect(
await page.evaluate(() =>
(window as unknown as { __editor: { getValue(): string } }).__editor.getValue(),
),
).toBe('X');
});

test('the editor is focused after a page reload', async ({ page }) => {
await page.reload();
await page.waitForFunction(
() => (window as unknown as { __appReady?: unknown }).__appReady !== undefined,
);
await page.evaluate(() => (window as unknown as { __appReady: Promise<void> }).__appReady);
await expect.poll(() => inEditor(page)).toBe(true);
// Can keep typing without clicking first.
await page.keyboard.type('Y');
expect(
await page.evaluate(() =>
(window as unknown as { __editor: { getValue(): string } }).__editor.getValue(),
),
).toContain('Y');
});
});
Loading