feat: esc to cancel select menus

feat: fieldVisibility tool
This commit is contained in:
Emmaline Autumn 2025-06-06 12:54:44 -06:00
parent 7c19ada88b
commit 04d5044c43
5 changed files with 195 additions and 15 deletions

View File

@ -6,12 +6,13 @@ interface EventMap {
exit: Event; exit: Event;
enter: Event; enter: Event;
backspace: Event; backspace: Event;
escape: Event;
delete: Event; delete: Event;
"arrow-left": Event; "arrow-left": Event;
"arrow-right": Event; "arrow-right": Event;
"arrow-up": Event; "arrow-up": Event;
"arrow-down": Event; "arrow-down": Event;
[key: string]: Event; // [key: string]: Event;
} }
interface EventDetailMap { interface EventDetailMap {
@ -146,6 +147,12 @@ export class InputManager extends ManagerEventTarget {
continue; continue;
} }
if (byte === 27 && i + 1 >= n) {
this.dispatchEvent(new Event("escape"));
i++;
continue;
}
// Escape sequences // Escape sequences
if (byte === 27 && i + 1 < n && buf[i + 1] === 91) { if (byte === 27 && i + 1 < n && buf[i + 1] === 91) {
const code = buf[i + 2]; const code = buf[i + 2];
@ -193,4 +200,23 @@ export class InputManager extends ManagerEventTarget {
if (raw) await Deno.stdin.setRaw(false); if (raw) await Deno.stdin.setRaw(false);
} }
dispatchKey(key: string) {
switch (key) {
case "enter":
case "backspace":
case "arrow-up":
case "arrow-down":
case "arrow-right":
case "arrow-left":
case "delete":
case "escape":
this.dispatchEvent(new Event(key));
break;
default:
this.dispatchEvent(
new CLICharEvent({ key: key.charCodeAt(0), char: key }),
);
}
}
} }

View File

@ -14,6 +14,7 @@ const toolRegistry: [string, Promise<{ default: ITool }>][] = [
["fieldRename", import("../tools/fieldRename.ts")], ["fieldRename", import("../tools/fieldRename.ts")],
["listFormFields", import("../tools/listFormFields.ts")], ["listFormFields", import("../tools/listFormFields.ts")],
["deleteFields", import("../tools/deleteFields.ts")], ["deleteFields", import("../tools/deleteFields.ts")],
["fieldVisibility", import("../tools/fieldVisibility.ts")],
]; ];
export class PdfToolsCli { export class PdfToolsCli {

View File

@ -1,5 +1,4 @@
import type { callback } from "../types.ts"; import type { callback } from "../types.ts";
import { log } from "util/logfile.ts";
import { type CLICharEvent, InputManager } from "./InputManager.ts"; import { type CLICharEvent, InputManager } from "./InputManager.ts";
import { cliLog } from "./prompts.ts"; import { cliLog } from "./prompts.ts";
import { colorize } from "./style.ts"; import { colorize } from "./style.ts";
@ -92,7 +91,17 @@ export async function selectMenuInteractive(
inputBuffer = inputBuffer.slice(0, -1); inputBuffer = inputBuffer.slice(0, -1);
}; };
let resolve: null | ((value: string) => void) = null; let resolve: null | ((value: string | null) => void) = null;
const onEscape = () => {
im.removeEventListener("arrow-up", onUp);
im.removeEventListener("arrow-down", onDown);
im.removeEventListener("char", onKey);
im.removeEventListener("backspace", onBackspace);
im.removeEventListener("enter", onEnter);
im.removeEventListener("escape", onEscape);
resolve?.(null);
};
const onEnter = (e: Event) => { const onEnter = (e: Event) => {
e.stopImmediatePropagation(); e.stopImmediatePropagation();
@ -108,22 +117,24 @@ export async function selectMenuInteractive(
im.removeEventListener("char", onKey); im.removeEventListener("char", onKey);
im.removeEventListener("backspace", onBackspace); im.removeEventListener("backspace", onBackspace);
im.removeEventListener("enter", onEnter); im.removeEventListener("enter", onEnter);
im.removeEventListener("escape", onEscape);
resolve?.(options[selected]); resolve?.(options[selected]);
}; };
renderMenu(); renderMenu();
await new Promise<string>((res) => { const final = await new Promise<string | null>((res) => {
resolve = res; resolve = res;
im.addEventListener("char", onKey); im.addEventListener("char", onKey);
im.addEventListener("backspace", onBackspace); im.addEventListener("backspace", onBackspace);
im.addEventListener("enter", onEnter); im.addEventListener("enter", onEnter);
im.addEventListener("arrow-up", onUp); im.addEventListener("arrow-up", onUp);
im.addEventListener("arrow-down", onDown); im.addEventListener("arrow-down", onDown);
im.addEventListener("escape", onEscape);
}); });
terminalBlock.setLines(["Selected: " + options[selected]], range); terminalBlock.setLines(["Selected: " + final], range);
return options[selected]; return final;
} }
export async function multiSelectMenuInteractive( export async function multiSelectMenuInteractive(
@ -151,10 +162,8 @@ export async function multiSelectMenuInteractive(
const checkSelectAll = () => { const checkSelectAll = () => {
if (selectedOptions.includes(0)) { if (selectedOptions.includes(0)) {
log("yeet");
selectedOptions = []; selectedOptions = [];
} else { } else {
log("neat");
selectedOptions = Array.from(options).map((_, i) => i); selectedOptions = Array.from(options).map((_, i) => i);
} }
}; };
@ -195,7 +204,7 @@ export async function multiSelectMenuInteractive(
const im = InputManager.getInstance(); const im = InputManager.getInstance();
im.activate(); im.activate();
let resolve = null as null | ((value: number[]) => void); let resolve = null as null | ((value: number[] | null) => void);
const onUp = (e: Event) => { const onUp = (e: Event) => {
e.stopImmediatePropagation(); e.stopImmediatePropagation();
@ -223,24 +232,36 @@ export async function multiSelectMenuInteractive(
renderMenu(); renderMenu();
}; };
const onEnter = (e: Event) => { const onEscape = () => {
e.stopImmediatePropagation();
resolve?.(selectedOptions);
im.removeEventListener("arrow-up", onUp); im.removeEventListener("arrow-up", onUp);
im.removeEventListener("arrow-down", onDown); im.removeEventListener("arrow-down", onDown);
im.removeEventListener("char", onSpace); im.removeEventListener("char", onSpace);
im.removeEventListener("enter", onEnter); im.removeEventListener("enter", onEnter);
im.removeEventListener("escape", onEscape);
resolve?.(null);
};
const onEnter = (e: Event) => {
e.stopImmediatePropagation();
im.removeEventListener("arrow-up", onUp);
im.removeEventListener("arrow-down", onDown);
im.removeEventListener("char", onSpace);
im.removeEventListener("enter", onEnter);
im.removeEventListener("escape", onEscape);
resolve?.(selectedOptions);
}; };
renderMenu(); renderMenu();
const selections = await new Promise<number[]>((res) => { const selections = await new Promise<number[] | null>((res) => {
resolve = res; resolve = res;
im.addEventListener("arrow-up", onUp); im.addEventListener("arrow-up", onUp);
im.addEventListener("arrow-down", onDown); im.addEventListener("arrow-down", onDown);
im.addEventListener("char", onSpace); im.addEventListener("char", onSpace);
im.addEventListener("enter", onEnter); im.addEventListener("enter", onEnter);
im.addEventListener("escape", onEscape);
}); });
if (!selections) return null;
for (const optionI of selections) { for (const optionI of selections) {
const option = options[optionI]; const option = options[optionI];
if (Array.isArray(option)) { if (Array.isArray(option)) {
@ -322,7 +343,7 @@ if (import.meta.main) {
"yuzu", "yuzu",
"zucchini", "zucchini",
], { terminalBlock: block, allOption: true }); ], { terminalBlock: block, allOption: true });
cliLog(val || "No value"); cliLog(val || "No value", block);
// Deno.stdout.writeSync(new TextEncoder().encode("\x07")); // Deno.stdout.writeSync(new TextEncoder().encode("\x07"));
} }

View File

@ -4,7 +4,6 @@ import { multiSelectMenuInteractive } from "../cli/selectMenu.ts";
import { TerminalBlock } from "../cli/TerminalLayout.ts"; import { TerminalBlock } from "../cli/TerminalLayout.ts";
import type { callback, ITool } from "../types.ts"; import type { callback, ITool } from "../types.ts";
import { loadPdf, savePdf } from "util/saveLoadPdf.ts"; import { loadPdf, savePdf } from "util/saveLoadPdf.ts";
import { log } from "util/logfile.ts";
export class DeleteFormFields implements ITool { export class DeleteFormFields implements ITool {
name = "deleteFormFields"; name = "deleteFormFields";

133
tools/fieldVisibility.ts Normal file
View File

@ -0,0 +1,133 @@
import { forceArgs } from "../cli/forceArgs.ts";
import { selectMenuInteractive } from "../cli/selectMenu.ts";
import { TerminalBlock } from "../cli/TerminalLayout.ts";
import type { ITool } from "../types.ts";
import { loadPdf, savePdf } from "util/saveLoadPdf.ts";
import { PDFName, type PDFNumber, type PDFWidgetAnnotation } from "pdf-lib";
import { cliPrompt } from "../cli/prompts.ts";
type AcrobatVisibility =
| "Visible"
| "Hidden"
| "VisibleButDoesNotPrint"
| "HiddenButPrintable";
export function getAcrobatVisibility(
widget: PDFWidgetAnnotation,
): AcrobatVisibility {
const raw = widget.dict.lookup(PDFName.of("F")) as PDFNumber;
const flags = raw?.asNumber?.() ?? 0;
const isInvisible = (flags & (1 << 1)) !== 0;
const isHidden = (flags & (1 << 2)) !== 0;
const isNoPrint = (flags & (1 << 3)) !== 0;
const isPrint = (flags & (1 << 2)) === 0 && !isNoPrint;
if (isInvisible && isHidden) {
return isPrint ? "HiddenButPrintable" : "Hidden";
} else if (isNoPrint) {
return "VisibleButDoesNotPrint";
} else {
return "Visible";
}
}
export function setAcrobatVisibility(
widget: PDFWidgetAnnotation,
visibility: AcrobatVisibility,
) {
let flags = 0;
switch (visibility) {
case "Visible":
// No visibility bits set
break;
case "Hidden":
flags |= 1 << 1; // Invisible
flags |= 1 << 2; // Hidden
break;
case "VisibleButDoesNotPrint":
flags |= 1 << 3; // NoPrint
break;
case "HiddenButPrintable":
flags |= 1 << 1; // Invisible
flags |= 1 << 2; // Hidden
flags |= 1 << 3; // NoPrint — UNset this to allow print
break;
}
widget.dict.set(PDFName.of("F"), widget.dict.context.obj(flags));
}
export class FieldVisibility implements ITool {
name = "Field Visibility";
description = "Change visibility of fields";
block?: TerminalBlock;
async run(pdfPath: string) {
if (!this.block) this.block = new TerminalBlock();
this.block.setPreserveHistory(false);
[pdfPath] = await forceArgs([pdfPath], [[
"Please provide path to PDF:",
(e) => e?.endsWith(".pdf"),
]], this.block);
const pdf = await loadPdf(pdfPath);
const form = pdf.getForm();
const fields = form.getFields();
let changesMade = false;
while (true) {
const fieldAndVisibility = await selectMenuInteractive(
`Select a field to change visibility (ESC to ${
changesMade ? "continue" : "cancel"
})`,
fields.flatMap((f) => {
const name = f.getName();
const visibility = f.acroField.getWidgets().map((w, i, a) =>
`${name}${a.length > 1 ? "#" + i : ""} :: ${
getAcrobatVisibility(w)
}`
);
return visibility;
}),
);
if (!fieldAndVisibility) break;
const visibility = await selectMenuInteractive(
fieldAndVisibility,
[
"Visible",
"Hidden",
"HiddenButPrintable",
"VisibleButDoesNotPrint",
] as AcrobatVisibility[],
) as AcrobatVisibility | null;
if (!visibility) continue;
const [fName, widgetIndex] = fieldAndVisibility.split("::")[0].trim()
.split("#");
const field = fields.find((f) => f.getName() === fName);
if (!field) break;
const widget = field.acroField.getWidgets()[Number(widgetIndex) || 0];
setAcrobatVisibility(widget, visibility);
changesMade = true;
}
if (changesMade) {
const path = await cliPrompt(
"Save to path (or hit enter to keep current):",
this.block,
) || pdfPath;
savePdf(pdf, path);
}
}
help?: (() => Promise<void> | void) | undefined;
done?: (() => Promise<void> | void) | undefined;
setBlock(block: TerminalBlock) {
this.block = block;
}
}
export default new FieldVisibility();