diff --git a/.github/workflows/finalize-v005.yml b/.github/workflows/finalize-v005.yml
deleted file mode 100644
index f03c458..0000000
--- a/.github/workflows/finalize-v005.yml
+++ /dev/null
@@ -1,341 +0,0 @@
-name: Finalize v0.0.5
-
-on:
- pull_request:
- branches:
- - main
-
-permissions:
- contents: write
-
-jobs:
- finalize:
- if: ${{ github.actor != 'github-actions[bot]' }}
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- ref: fix/v0.0.5-finalize-extension
- fetch-depth: 0
-
- - name: Apply v0.0.5 fixes
- shell: python
- run: |
- from pathlib import Path
- import json
- import struct
- import zlib
-
- def replace_once(text, old, new, label):
- if old not in text:
- raise RuntimeError(f'Could not find {label}')
- return text.replace(old, new, 1)
-
- package_path = Path('package.json')
- package = json.loads(package_path.read_text(encoding='utf-8'))
- package['version'] = '0.0.5'
- package['contributes']['configuration']['properties']['pdfViewerEditor.defaultZoom']['default'] = 1
- package['icon'] = 'images/icon.png'
- package_path.write_text(json.dumps(package, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
-
- lock_path = Path('package-lock.json')
- lock = lock_path.read_text(encoding='utf-8')
- lock = lock.replace('"version": "0.0.4"', '"version": "0.0.5"', 2)
- lock_path.write_text(lock, encoding='utf-8')
-
- main_path = Path('media/webview/main.js')
- main = main_path.read_text(encoding='utf-8')
- main = replace_once(main, ' zoom: 1.25,', ' zoom: 1,', 'initial zoom')
- main = replace_once(main, ' defaultZoom: 1.25,', ' defaultZoom: 1,', 'settings default zoom')
- main = replace_once(main, " state.zoom = Number(state.settings.defaultZoom || 1.25);", " state.zoom = Number(state.settings.defaultZoom || 1);", 'load zoom fallback')
-
- old_click = '''function textLineClicked(event) {
- if (state.tool !== 'edit' || state.busy) {
- return;
- }
- hideInsertMenu();
- const blockIndex = Number(event.currentTarget.dataset.blockIndex);
- window.requestAnimationFrame(() => {
- const selection = window.getSelection();
- if (selection && !selection.isCollapsed && selectionInsideTextLayer(selection)) {
- updateTextRangeFromSelection();
- } else {
- selectTextBlock(blockIndex);
- }
- });
- }
- '''
- new_click = '''function textLineClicked(event) {
- if (state.tool !== 'edit' || state.busy) {
- return;
- }
- hideInsertMenu();
-
- if (event.detail >= 2) {
- window.requestAnimationFrame(updateTextRangeFromSelection);
- return;
- }
-
- window.requestAnimationFrame(() => {
- const selection = window.getSelection();
- if (selection && !selection.isCollapsed && selectionInsideTextLayer(selection)) {
- updateTextRangeFromSelection();
- return;
- }
- selectTextLine(event.currentTarget);
- });
- }
-
- function selectTextLine(lineElement) {
- if (!(lineElement instanceof window.HTMLElement)) {
- return false;
- }
- const selection = window.getSelection();
- if (!selection) {
- return false;
- }
- const range = document.createRange();
- range.selectNodeContents(lineElement);
- selection.removeAllRanges();
- selection.addRange(range);
- updateTextRangeFromSelection();
- return true;
- }
- '''
- main = replace_once(main, old_click, new_click, 'textLineClicked implementation')
- main = replace_once(
- main,
- " line.classList.toggle('selected', Number(line.dataset.blockIndex) === blockIndex);",
- " line.classList.toggle(\n 'selected',\n !options.preserveRange && Number(line.dataset.blockIndex) === blockIndex\n );",
- 'block highlight toggle'
- )
- main = replace_once(
- main,
- ''' } else if (message.type === 'operation-error') {
- setBusy(false);
- setStatus(message.message, 'error');
- }
- ''',
- ''' } else if (message.type === 'operation-error') {
- setBusy(false);
- setStatus(message.message, 'error');
- } else if (message.type === 'save-complete') {
- setBusy(false);
- setStatus(String(message.message || 'PDF guardado.'));
- }
- ''',
- 'save-complete message handler'
- )
- main_path.write_text(main, encoding='utf-8')
-
- css_path = Path('media/webview/styles.css')
- css = css_path.read_text(encoding='utf-8')
- css = replace_once(
- css,
- '''.text-layer-line.selected {
- background: color-mix(in srgb, var(--focus) 12%, transparent);
- box-shadow: inset 2px 0 0 color-mix(in srgb, var(--focus) 75%, transparent);
- }
- ''',
- '''.text-layer-line.selected {
- background: transparent;
- box-shadow: none;
- }
- ''',
- 'selected text line style'
- )
- css_path.write_text(css, encoding='utf-8')
-
- html_path = Path('src/webview-html.js')
- html = html_path.read_text(encoding='utf-8')
- html = replace_once(html, '', '', '100 percent option')
- html = replace_once(html, '', '', '125 percent option')
- html_path.write_text(html, encoding='utf-8')
-
- provider_path = Path('src/pdf-editor-provider.js')
- provider = provider_path.read_text(encoding='utf-8')
- provider = replace_once(provider, " await this.executeEditorCommand(String(message.command || ''));", " await this.executeEditorCommand(document, panel, String(message.command || ''));", 'command invocation')
- old_execute = ''' /**
- * @param {string} command
- */
- async executeEditorCommand(command) {
- const allowedCommands = new Map([
- ['save', 'workbench.action.files.save'],
- ['saveAs', 'workbench.action.files.saveAs'],
- ['undo', 'undo'],
- ['redo', 'redo']
- ]);
- const vscodeCommand = allowedCommands.get(command);
- if (vscodeCommand) {
- await vscode.commands.executeCommand(vscodeCommand);
- }
- }
- '''
- new_execute = ''' /**
- * @param {PdfDocument} document
- * @param {vscode.WebviewPanel} panel
- * @param {string} command
- */
- async executeEditorCommand(document, panel, command) {
- if (command === 'save') {
- let savedUri;
- if (typeof vscode.workspace.save === 'function') {
- savedUri = await vscode.workspace.save(document.uri);
- } else {
- panel.reveal(panel.viewColumn, true);
- await vscode.commands.executeCommand('workbench.action.files.save');
- savedUri = document.uri;
- }
- if (!savedUri) {
- await this.saveCustomDocument(document);
- savedUri = document.uri;
- }
- await panel.webview.postMessage({
- type: 'save-complete',
- message: `Guardado: ${path.basename(savedUri.fsPath || savedUri.path)}`
- });
- return;
- }
-
- if (command === 'saveAs') {
- let savedUri;
- if (typeof vscode.workspace.saveAs === 'function') {
- savedUri = await vscode.workspace.saveAs(document.uri);
- } else {
- panel.reveal(panel.viewColumn, true);
- await vscode.commands.executeCommand('workbench.action.files.saveAs');
- }
- await panel.webview.postMessage({
- type: 'save-complete',
- message: savedUri
- ? `Guardado como: ${path.basename(savedUri.fsPath || savedUri.path)}`
- : 'Guardar como cancelado.'
- });
- return;
- }
-
- const allowedCommands = new Map([
- ['undo', 'undo'],
- ['redo', 'redo']
- ]);
- const vscodeCommand = allowedCommands.get(command);
- if (vscodeCommand) {
- await vscode.commands.executeCommand(vscodeCommand);
- }
- }
- '''
- provider = replace_once(provider, old_execute, new_execute, 'executeEditorCommand')
- provider = replace_once(provider, " defaultZoom: configuration.get('defaultZoom', 1.25),", " defaultZoom: configuration.get('defaultZoom', 1),", 'provider zoom fallback')
- provider_path.write_text(provider, encoding='utf-8')
-
- width = height = 256
- background = (229, 57, 53, 255)
- white = (255, 255, 255, 255)
- accent = (33, 33, 33, 255)
- pixels = [[background for _ in range(width)] for _ in range(height)]
-
- def rect(x1, y1, x2, y2, color):
- for y in range(max(0, y1), min(height, y2)):
- for x in range(max(0, x1), min(width, x2)):
- pixels[y][x] = color
-
- rect(52, 30, 184, 226, white)
- rect(184, 78, 205, 226, white)
- for y in range(30, 79):
- x2 = min(205, 184 + max(0, (y - 30) // 2))
- rect(184, y, x2 + 1, y + 1, white)
- rect(80, 105, 174, 119, background)
- rect(80, 137, 174, 151, background)
- rect(80, 169, 151, 183, background)
- for i in range(52):
- for t in range(10):
- x = 151 + i - t // 2
- y = 202 - i + t
- if 0 <= x < width and 0 <= y < height:
- pixels[y][x] = accent
-
- raw = bytearray()
- for row in pixels:
- raw.append(0)
- for rgba in row:
- raw.extend(rgba)
-
- def chunk(kind, data):
- return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data) & 0xffffffff)
-
- png = bytearray(b'\x89PNG\r\n\x1a\n')
- png += chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0))
- png += chunk(b'IDAT', zlib.compress(bytes(raw), 9))
- png += chunk(b'IEND', b'')
- Path('images').mkdir(exist_ok=True)
- Path('images/icon.png').write_bytes(png)
-
- changelog_path = Path('CHANGELOG.md')
- changelog = changelog_path.read_text(encoding='utf-8')
- marker = 'Todos los cambios relevantes de este proyecto se documentan aquí.\n'
- section = '''\n## 0.0.5 - 2026-08-24\n\n### Corregido\n\n- La selección de texto usa exclusivamente el rango nativo: arrastrar selecciona solo palabras/frases y ya no pinta el párrafo completo en azul.\n- Un clic selecciona únicamente la línea visible; doble clic conserva la selección nativa de palabra.\n- El zoom inicial pasa de 125% a 100% en configuración, webview y proveedor.\n- `Guardar` y `Guardar como` se dirigen al PDF abierto y muestran confirmación en el editor.\n- Icono de extensión renovado y validado dentro del manifiesto VSIX para VS Code Marketplace.\n\n### Pruebas\n\n- El empaquetado comprueba versión 0.0.5, PNG 256×256, asset de icono en `extension.vsixmanifest`, ausencia del resaltado azul de bloque y presencia del guardado dirigido por URI.\n'''
- if '## 0.0.5 - 2026-08-24' not in changelog:
- changelog = replace_once(changelog, marker, marker + section, 'changelog insertion point')
- changelog_path.write_text(changelog, encoding='utf-8')
-
- readme_path = Path('README.md')
- readme = readme_path.read_text(encoding='utf-8')
- readme = readme.replace('La versión `0.0.4`', 'La versión `0.0.5`', 1)
- readme = readme.replace('- Hacer clic sobre cualquier línea para seleccionar solo su párrafo visual, sin rectángulos gigantes.', '- Hacer clic sobre una línea selecciona solo esa línea; arrastrar selecciona exactamente palabras o frases, sin rectángulos gigantes.', 1)
- readme_path.write_text(readme, encoding='utf-8')
-
- - name: Validate implementation invariants
- shell: bash
- run: |
- set -euo pipefail
- node -e "const p=require('./package.json'); if(p.version!=='0.0.5') process.exit(1); if(p.contributes.configuration.properties['pdfViewerEditor.defaultZoom'].default!==1) process.exit(2); if(p.icon!=='images/icon.png') process.exit(3)"
- grep -q "selectTextLine(event.currentTarget)" media/webview/main.js
- grep -q "!options.preserveRange" media/webview/main.js
- grep -q "background: transparent" media/webview/styles.css
- grep -q "vscode.workspace.save(document.uri)" src/pdf-editor-provider.js
- grep -q "defaultZoom: configuration.get('defaultZoom', 1)" src/pdf-editor-provider.js
- grep -q '' src/webview-html.js
-
- - name: Install and test
- run: |
- npm ci
- npm run check
-
- - name: Package and verify Marketplace assets
- shell: bash
- run: |
- set -euo pipefail
- npm run package
- test -f pdf-viewer-editor-0.0.5.vsix
- python - <<'PY'
- from pathlib import Path
- import struct
- import zipfile
-
- vsix = Path('pdf-viewer-editor-0.0.5.vsix')
- with zipfile.ZipFile(vsix) as archive:
- names = set(archive.namelist())
- assert 'extension/images/icon.png' in names
- manifest = archive.read('extension.vsixmanifest').decode('utf-8')
- assert 'Version="0.0.5" Publisher="suzdalenko-dev"' in manifest
- assert 'extension/images/icon.png' in manifest
- assert 'Microsoft.VisualStudio.Services.Icons.Default' in manifest
- icon = archive.read('extension/images/icon.png')
- assert icon[:8] == b'\x89PNG\r\n\x1a\n'
- width, height = struct.unpack('>II', icon[16:24])
- assert (width, height) == (256, 256)
- print(f'VSIX OK: {vsix.name}; icon={width}x{height}; files={len(names)}')
- PY
- rm -f pdf-viewer-editor-0.0.5.vsix
-
- - name: Commit final source and remove one-shot workflow
- shell: bash
- run: |
- set -euo pipefail
- rm -f .github/workflows/finalize-v005.yml
- git config user.name 'github-actions[bot]'
- git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
- git add -A
- git status --short
- git commit -m 'fix: finalize v0.0.5 editor interactions and marketplace package'
- git push origin HEAD:fix/v0.0.5-finalize-extension
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 783d7ec..a0e143d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,16 @@
Todos los cambios relevantes de este proyecto se documentan aquí.
+## 0.0.5 - 2026-08-24
+
+### Corregido
+
+- Selección nativa exacta de líneas, palabras y frases sin rectángulo azul de párrafo.
+- Zoom inicial al 100%.
+- Botón Guardar escribe el PDF abierto y muestra confirmación visual.
+- Icono 256×256 renovado y validado como asset del VSIX/Marketplace.
+
+
## 0.0.4 - 2026-08-23
### Corregido
diff --git a/README.md b/README.md
index bfc2b16..6f797bd 100644
--- a/README.md
+++ b/README.md
@@ -2,13 +2,13 @@
Editor visual de PDF gratuito para Visual Studio Code, construido en JavaScript con MuPDF.js y Fabric.js.
-La versión `0.0.4` se concentra en una tarea: **seleccionar y editar directamente el contenido visible de forma natural**. Abre el PDF como una pestaña editable de VS Code y participa en el ciclo normal de Guardar, Guardar como, Deshacer, Rehacer, recuperación y copias de seguridad.
+La versión `0.0.5` se concentra en una tarea: **seleccionar y editar directamente el contenido visible de forma natural**. Abre el PDF como una pestaña editable de VS Code y participa en el ciclo normal de Guardar, Guardar como, Deshacer, Rehacer, recuperación y copias de seguridad.
> Conserva una copia del documento original cuando trabajes con archivos importantes. Un PDF describe objetos colocados en coordenadas y no siempre contiene párrafos equivalentes a los de Word; esta extensión reconstruye bloques editables a partir de la estructura visual detectada por MuPDF.
## Edición de texto con reflujo
-- Hacer clic sobre cualquier línea para seleccionar solo su párrafo visual, sin rectángulos gigantes.
+- Hacer clic sobre una línea selecciona solo esa línea; arrastrar selecciona exactamente palabras o frases, sin rectángulos gigantes.
- Arrastrar sobre una palabra o frase y editar, borrar, copiar o cambiar su formato.
- Editar un párrafo completo con `Editar contenido`.
- Añadir texto en cualquier punto de la página.
diff --git a/images/icon.png b/images/icon.png
index 5e13d79..afb21c7 100644
Binary files a/images/icon.png and b/images/icon.png differ
diff --git a/media/webview/main.js b/media/webview/main.js
index b836ed6..ac44d98 100644
--- a/media/webview/main.js
+++ b/media/webview/main.js
@@ -17,11 +17,11 @@ const state = {
fileName: 'document.pdf',
currentPage: 0,
pageCount: 0,
- zoom: 1.25,
+ zoom: 1,
zoomMode: 'numeric',
tool: 'edit',
settings: {
- defaultZoom: 1.25,
+ defaultZoom: 1,
maxRenderPixels: 24_000_000,
defaultSaveMode: 'incremental'
},
@@ -74,6 +74,9 @@ window.addEventListener('message', async (event) => {
} else if (message.type === 'operation-error') {
setBusy(false);
setStatus(message.message, 'error');
+ } else if (message.type === 'document-saved') {
+ setBusy(false);
+ setStatus('PDF guardado correctamente.');
}
});
@@ -90,7 +93,7 @@ async function loadDocument(message) {
state.revision = Number(message.revision || 0);
state.fileName = String(message.fileName || 'document.pdf');
state.settings = { ...state.settings, ...(message.settings || {}) };
- state.zoom = Number(state.settings.defaultZoom || 1.25);
+ state.zoom = Number(state.settings.defaultZoom || 1);
state.zoomMode = 'numeric';
state.password = '';
state.searchQuery = '';
@@ -264,14 +267,22 @@ function textLineClicked(event) {
return;
}
hideInsertMenu();
- const blockIndex = Number(event.currentTarget.dataset.blockIndex);
+ if (event.detail >= 2) {
+ window.requestAnimationFrame(updateTextRangeFromSelection);
+ return;
+ }
+ const line = event.currentTarget;
window.requestAnimationFrame(() => {
const selection = window.getSelection();
if (selection && !selection.isCollapsed && selectionInsideTextLayer(selection)) {
updateTextRangeFromSelection();
- } else {
- selectTextBlock(blockIndex);
+ return;
}
+ const range = document.createRange();
+ range.selectNodeContents(line);
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ updateTextRangeFromSelection();
});
}
@@ -295,9 +306,7 @@ function selectTextBlock(blockIndex, options = {}) {
state.fabricCanvas.discardActiveObject();
state.fabricCanvas.requestRenderAll();
}
- for (const line of elements['text-layer'].querySelectorAll('.text-layer-line')) {
- line.classList.toggle('selected', Number(line.dataset.blockIndex) === blockIndex);
- }
+ clearTextLineHighlights();
selectMeta(meta);
return true;
}
@@ -1552,7 +1561,13 @@ function initializeEventHandlers() {
});
elements['undo-button'].addEventListener('click', () => postCommand('undo'));
elements['redo-button'].addEventListener('click', () => postCommand('redo'));
- elements['save-button'].addEventListener('click', () => postCommand('save'));
+ elements['save-button'].addEventListener('click', () => {
+ if (state.busy) {
+ return;
+ }
+ setBusy(true, 'Guardando PDF…');
+ postCommand('save');
+ });
elements['edit-text-tool'].addEventListener('click', () => {
hideInsertMenu();
diff --git a/package-lock.json b/package-lock.json
index 28239b0..a2af34b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "pdf-viewer-editor",
- "version": "0.0.4",
+ "version": "0.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pdf-viewer-editor",
- "version": "0.0.4",
+ "version": "0.0.5",
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@types/node": "^24.0.0",
diff --git a/package.json b/package.json
index 006cdaa..71cc502 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "pdf-viewer-editor",
"displayName": "PDF Viewer & Editor",
"description": "A focused visual PDF editor with direct text selection, real reflow, image editing, and editable tables in Visual Studio Code.",
- "version": "0.0.4",
+ "version": "0.0.5",
"publisher": "suzdalenko-dev",
"license": "AGPL-3.0-or-later",
"repository": {
@@ -82,7 +82,7 @@
"properties": {
"pdfViewerEditor.defaultZoom": {
"type": "number",
- "default": 1.25,
+ "default": 1,
"minimum": 0.25,
"maximum": 5,
"description": "Default PDF page zoom factor."
@@ -128,5 +128,9 @@
"mupdf": "1.28.0",
"typescript": "^5.9.0"
},
- "icon": "images/icon.png"
+ "icon": "images/icon.png",
+ "galleryBanner": {
+ "color": "#0f172a",
+ "theme": "dark"
+ }
}
diff --git a/src/pdf-editor-provider.js b/src/pdf-editor-provider.js
index 85114be..ac108eb 100644
--- a/src/pdf-editor-provider.js
+++ b/src/pdf-editor-provider.js
@@ -110,7 +110,7 @@ class PdfEditorProvider {
}
case 'command':
- await this.executeEditorCommand(String(message.command || ''));
+ await this.executeEditorCommand(document, panel, String(message.command || ''));
return;
case 'export':
@@ -150,11 +150,19 @@ class PdfEditorProvider {
}
/**
+ * @param {PdfDocument} document
+ * @param {vscode.WebviewPanel} panel
* @param {string} command
*/
- async executeEditorCommand(command) {
+ async executeEditorCommand(document, panel, command) {
+ if (command === 'save') {
+ await vscode.workspace.fs.writeFile(document.uri, document.data);
+ await vscode.commands.executeCommand('workbench.action.files.save');
+ await panel.webview.postMessage({ type: 'document-saved' });
+ return;
+ }
+
const allowedCommands = new Map([
- ['save', 'workbench.action.files.save'],
['saveAs', 'workbench.action.files.saveAs'],
['undo', 'undo'],
['redo', 'redo']
@@ -222,7 +230,7 @@ class PdfEditorProvider {
revision: document.revision,
fileName: path.basename(document.uri.fsPath || document.uri.path),
settings: {
- defaultZoom: configuration.get('defaultZoom', 1.25),
+ defaultZoom: configuration.get('defaultZoom', 1),
maxRenderPixels: configuration.get('maxRenderPixels', 24000000),
defaultSaveMode: configuration.get('defaultSaveMode', 'incremental')
}
diff --git a/src/webview-html.js b/src/webview-html.js
index 94b2ff7..224665a 100644
--- a/src/webview-html.js
+++ b/src/webview-html.js
@@ -48,8 +48,8 @@ function getWebviewHtml(webview, extensionUri) {
-
-
+
+
diff --git a/test/ui-contract.test.mjs b/test/ui-contract.test.mjs
new file mode 100644
index 0000000..d2dc6ff
--- /dev/null
+++ b/test/ui-contract.test.mjs
@@ -0,0 +1,31 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import test from 'node:test';
+import { URL } from 'node:url';
+
+const main = fs.readFileSync(new URL('../media/webview/main.js', import.meta.url), 'utf8');
+const provider = fs.readFileSync(new URL('../src/pdf-editor-provider.js', import.meta.url), 'utf8');
+const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
+
+test('default zoom is 100 percent throughout the shipped extension', () => {
+ assert.equal(pkg.contributes.configuration.properties['pdfViewerEditor.defaultZoom'].default, 1);
+ assert.match(main, /zoom: 1,/);
+ assert.match(provider, /configuration\.get\('defaultZoom', 1\)/);
+});
+
+test('single-click text selection uses a native line range instead of paragraph-wide highlight', () => {
+ assert.match(main, /range\.selectNodeContents\(line\)/);
+ assert.doesNotMatch(main, /line\.classList\.toggle\('selected', Number\(line\.dataset\.blockIndex\) === blockIndex\)/);
+});
+
+test('save writes the custom document and sends visible acknowledgement', () => {
+ assert.match(provider, /workspace\.fs\.writeFile\(document\.uri, document\.data\)/);
+ assert.match(provider, /type: 'document-saved'/);
+ assert.match(main, /PDF guardado correctamente/);
+});
+
+test('marketplace icon metadata is present', () => {
+ assert.equal(pkg.icon, 'images/icon.png');
+ assert.equal(fs.existsSync(new URL('../images/icon.png', import.meta.url)), true);
+ assert.equal(pkg.version, '0.0.5');
+});