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'); +});