Author SHA1 Message Date
Tyler 3915865029 Keep ai model saved when app closes. 2026-06-26 08:48:38 -05:00
Tyler 79edb6bb56 Change model
Ran into an issue with the previous model for having mllama archetecture so I changed it
2026-06-26 08:36:45 -05:00
Tyler a971ff870f Added feature to where local ai models can see the image in each step. 2026-06-26 08:05:18 -05:00
TylerandGitHub 9bd4a959c4 Semantic capture titles, AI documentation assist, and keyboard tracking (#3)
Template tests / tests (push) Successful in 1m58s
Semantic capture titles, AI documentation assist, and keyboard tracking
2026-06-24 12:11:11 -05:00
TylerandClaude Sonnet 4.6 ebeb6ac578 label Enable AI setting as experimental
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 12:04:40 -05:00
TylerandClaude Sonnet 4.6 f60cf1a688 label generate-all-steps menu item as experimental
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 12:02:59 -05:00
TylerandClaude Sonnet 4.6 2d1b474931 fix generate-all-steps: never overwrite existing content, right target per step
The previous version called target:'all' on every step unconditionally,
which caused the AI to rewrite steps that already had good titles and
descriptions — often making them worse or inaccurate.

New behaviour:
- Build a queue of only the steps that are actually missing content
  (placeholder title and/or empty description).
- Determine target per step: 'title', 'description', or 'all' — only
  fill what is blank; leave existing user-written text completely alone.
- If all steps already have titles and descriptions, show a toast and stop.
- Call reload() once at the end instead of patching this.steps mid-loop,
  so the editor and step list both update atomically from the store.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 11:27:24 -05:00
TylerandClaude Sonnet 4.6 c60b7247af generate all text fields with AI now fills every step, not just current
Previously the menu action only ran AI generation on the selected step.
Now it loops through all steps sequentially, showing progress ("AI:
filling step N of M…"), then reloads the visible step when done.
Partial failures are reported in the final toast.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 11:22:27 -05:00
TylerandClaude Sonnet 4.6 cf056d2b97 stop AI from generating text/code/table blocks on generate-all
The model was producing Text and Code blocks on every 'generate all
fields' action — uninstructed, generic, and never useful for the user.

Blocks are only appropriate when the user explicitly edits an existing
block (target === 'block'). For title/description/all generation:
- Schema collapses to { "title", "description" } — no blocks key
- targetText no longer mentions blocks
- allowedBlockNote is null (omitted from prompt)
- Rule explicitly says "Do NOT add any blocks array"

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 11:18:41 -05:00
TylerandClaude Sonnet 4.6 3fa366a0d0 revert UIAutomation background thread — it stalled click detection
TryLoadUia() blocked for several seconds loading reflection assemblies at
ClickProcessorLoop startup, during which no CLICK events were emitted.
Restored MouseHookCallback to emit CTX + CLICK synchronously (fast Win32
only) as before — no startup cost, clicks are never delayed.

The wider OCR crop and smarter search-results title fallback are kept.
UIAutomation element lookup can be revisited with a pre-warmed approach.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 11:14:49 -05:00
TylerandClaude Sonnet 4.6 d0b1816175 fix step-behind titles: UIAutomation element label from click watcher + smarter search fallback
**Root cause**
When OCR fails, the title fell back to the browser window title.  For a
click on a search-results page, the window title reflects the *previous*
search query ("oracle - Google Search"), producing "Search for Oracle"
even though the user is clicking a link *on* that page.

**Fix 1: UIAutomation element label from the click watcher**
The C# click-watcher hook now enriches each click in a background thread
(ClickProcessorLoop) rather than in the hook callback:
- MouseHookCallback captures window title synchronously (fast Win32),
  then queues a PendingClick and returns immediately.
- ClickProcessorLoop calls AutomationElement.FromPoint() via reflection
  (no compile-time assembly reference → no startup failure if UIA is
  absent).  Wrapped in a 300ms timeout thread so slow UIA calls don't
  delay the click event past the frame buffer window.
- Emits CTX + ELEM (label/role/value) + CLICK as an atomic batch.

Node.js:
- Parses ELEM events, merges element info into _pendingWindowContext.
- clickMeta.windowContext now carries elementLabel/elementRole/elementValue
  in addition to windowTitle/appName.
- buildCaptureTitle priority-5 (element label) now fires from click-watcher
  data, giving "Select Oracle | Cloud Applications…" without OCR.

**Fix 2: Wider OCR crop**
ocrAroundClick now uses a full-display-width × 100px horizontal strip at
the click height.  The previous 420 px crop cropped through long link text
(e.g. "Oracle | Cloud Applications and Cloud Platform"), causing fragments
to be scored lower than the complete text.

**Fix 3: Search-results window title fallback**
extractSearchQuery now only produces "Search for Oracle" when recentTyped
is non-empty (the user was actually typing a query).  For a pure click on
the search-results page (no recent typing), the fallback is "Select a
Oracle result in Chrome" — honest about what we know without implying the
user performed the search in this step.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 11:05:34 -05:00
TylerandClaude Sonnet 4.6 dfa65c894b ci: only run tests on push to main, not on PR commits
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 10:53:30 -05:00
TylerandClaude Sonnet 4.6 162ae0ae05 improve AI rewrite UX and title quality
**AI button: dynamic tooltip hints**
- titleAiBtn and descAiBtn now show "Rewrite with AI" when the field
  already has user content, "Generate with AI" when empty.
- updateAiButtonHints() fires on every title/description input event
  and whenever syncStepFields() runs to keep hints current.

**Stronger rewrite prompt**
- When the user has typed a draft title or description, the prompt now
  shows it explicitly: "User's draft title (rewrite this): '...'"
- Rules changed from "improve its wording" to "Your only job is to
  polish its grammar and phrasing. Do NOT replace it with something
  different." — prevents the model from ignoring the user's text and
  generating fresh content from capture context.
- The suggested-title hint is suppressed when a draft title exists so
  the model doesn't silently swap the user's text for the auto-title.

**Title quality: generic window title filter**
- GENERIC_WINDOW_TITLES Set filters "New Tab", "Untitled", "Loading"
  etc. from the window-title path so they no longer produce titles
  like "Open New Tab in Chrome".

**Title quality: app name stripping for non-browser apps**
- stripBrowserNameSuffix now accepts an optional appName; it strips
  the app's display name and process name from the window title suffix
  using the same pattern as browser names.
- "Document1.docx - Word" with appName "winword" → "Document1.docx".
- buildCaptureTitle passes metadata.appName into the strip call.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 10:42:56 -05:00
TylerandClaude Sonnet 4.6 879434f14f fix: capture window title from click watcher instead of spawning PowerShell
Root cause of all-"Screen capture" titles: collectWindowsWindowContext
called execFileSync('powershell.exe') with a 1200 ms timeout, but
PowerShell cold-start on typical Windows systems takes 1-3 seconds.
Every capture timed out silently, returned empty metadata, and fell
back to "Screen capture".

Fix:
- The click watcher C# process (already running, already compiled)
  now emits a CTX event immediately before each CLICK event using
  GetForegroundWindow → GetWindowText + QueryFullProcessImageName.
  These are synchronous Win32 calls, sub-millisecond, no startup cost.
- CTX payload is base64-encoded to survive the line protocol safely:
    CTX <b64title> <b64app> <unixMs>
- processClickWatcherData parses CTX lines and stores the decoded
  strings in this._lastWindowContext.
- enqueueClickCapture attaches it as clickMeta.windowContext.
- buildCaptureContext uses it directly (Promise.resolve) when present,
  bypassing the PowerShell spawn entirely.

Fallback (manual captures without a session click watcher running):
- collectWindowsWindowContext is now async and uses execFile with a
  4 s timeout instead of execFileSync at 1200 ms, so it no longer
  blocks the event loop and has room to succeed on slower machines.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 10:16:11 -05:00
TylerandClaude Sonnet 4.6 13b5f67de8 add keyboard tracking and app-qualified smart titles
**Keyboard hook (Windows)**
- Extends the existing C# WH_MOUSE_LL process to also install
  WH_KEYBOARD_LL alongside it (keyboard hook is optional — failure
  does not break mouse capture).
- Emits CHAR <code> <ts> for printable keystrokes, KEY <name> <ts>
  for modifier combos (Ctrl+T) and special keys (Backspace, Enter).

**Text accumulation in capture session**
- CaptureService tracks _keyBuffer (typed chars since last step) and
  _lastShortcut (last modifier combo) using the new onKeyboardEvent()
  method.
- snapshotKeyContext() is called at enqueueClickCapture time so each
  step's clickMeta.keyContext carries { recentTyped, recentShortcut }.
- Buffer resets after each snapshot; stale input (>8s gap) is dropped.

**UIAutomation element value**
- collectWindowsWindowContext now reads ValuePattern.Current.Value
  from the clicked element — giving us what's actually typed in a
  search box or text field without needing the keyboard buffer.

**Smart title generation (core/text-intel.js)**
- Priority chain: keyboard shortcut → element value → typed text
  → OCR → element label → page title → app name.
- SHORTCUT_TITLES maps 50+ common shortcuts (Ctrl+T, Ctrl+S, F5 …)
  to natural language descriptions: "Open new tab", "Save", etc.
- qualifyTitleWithApp() appends "in Chrome / VS Code / Terminal / …"
  to every title when the app is known: "Click Save in VS Code",
  "Search for oracle in Chrome", "Open new tab in Chrome".
- APP_DISPLAY_NAMES covers browsers, editors, terminals, office apps.

Six new unit tests cover shortcuts, typed-text search, element value,
and app-qualified OCR titles. Capture test updated for keyContext.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 10:04:34 -05:00
TylerandClaude Sonnet 4.6 8d645c0aca fix smart title generation from browser window context
The title engine was falling back to "Screen capture" for all browser
captures because it treated the entire browser window title as noise.

Changes:
- stripBrowserNameSuffix: removes "- Google Chrome" / "| Firefox" etc.
  from the end of window titles, leaving just the page title.
  "oracle - Google Search - Google Chrome" → "oracle - Google Search"
  "Oracle | Cloud Applications - Google Chrome" → page title only

- extractSearchQuery: detects "[query] - Google Search" / Bing / etc.
  patterns after stripping the browser suffix, and formats the result
  as "Search for oracle".

- buildCaptureTitle: uses stripped page title + search detection before
  falling through to the "Screen capture" fallback.

- pickBestOcrPhrase: considers the full OCR line (≤80 chars) as a
  candidate with a +35 completeness bonus before splitting on | or ·.
  This preserves "Oracle | Cloud Applications and Cloud Platform" as a
  single phrase instead of breaking it into fragments.

- candidateWords: filters out standalone punctuation tokens (|, ·, •)
  so they don't inflate word-count penalties for compound brand names.

- verbForElementRole: hyperlinks and links now produce "Select" instead
  of "Click"; search box / search field produces "Search for".

Five new unit tests cover: browser title stripping, search query
extraction, full pipe-separated link text, link and search box verbs.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 09:44:36 -05:00
TylerandClaude Sonnet 4.6 68ceee83ef fix ai title generation and remove separate rewrite section
- Remove the standalone "AI Rewrite" section from the editor panel.
  The existing title and description AI buttons already flush the step
  first, so they rewrite whatever the user has typed in those fields.

- Never pass a generic fallback title ("Screen capture", "Window
  capture", "Region capture", "Capture") as the AI title candidate or
  as step content. It is now treated as "(not set — generate a
  specific action title)" so the AI always produces something real.

- hasRichCaptureContext now counts any non-trivial app name or window
  title as sufficient context, instead of requiring non-browser noise.

- Prompt rules updated: "NEVER output Screen/Window/Region capture",
  separate paths for improving a user draft vs generating from context,
  and clearer guidance when context is limited (use app/window name).

- isPlaceholderTitle helper guards summarizeStepForAi so a
  default-titled step presents itself as empty to the AI.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 09:27:23 -05:00
TylerandClaude Sonnet 4.6 d244626583 ai smart auto-documentation and text rewrite
- Store captureMetadata (OCR text, window/app/element info) with each
  step at capture time so AI always has the original rich context.
- Add buildCaptureContext() to TextIntelService; capture.js uses it
  instead of buildCaptureTitle() so both title and metadata come from
  one pass.
- generateStepPatch() prefers stored captureMetadata over re-running
  OCR, giving the AI the best possible context when the user clicks
  an AI button later.
- Add autoDoc setting: when enabled every capture (shoot, region, and
  session hotkey/click) is automatically documented by AI. Manual
  captures await AI before returning; session captures fire-and-forget
  and push a step:updated event so the renderer reloads seamlessly.
- Add ai:rewriteText IPC and rewriteText() method for plain-text
  polishing via a separate callOllamaText() that skips JSON mode.
- Add "AI Rewrite" section in the editor right panel: textarea + AI
  button that rewrites whatever the user types in place.
- Improve buildAiPrompt() rules: action-focused title instructions,
  explicit anti-junk rules (no "Capture the screen / OCR" blocks),
  and a context-quality gate that suppresses blocks when context is
  thin.
- Add autoDoc checkbox to AI settings dialog.
- Renderer handles step:updated to reload the selected step after
  background auto-doc finishes.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 09:17:31 -05:00
Tyler 375c14b028 prefer OCR for capture titles 2026-06-24 08:55:19 -05:00
Tyler 1a1dbb846b fix settings AI test connection wiring 2026-06-23 18:14:12 -05:00
Tyler 0bd326d8b2 harden Electron startup repair 2026-06-23 18:10:35 -05:00
Tyler bd10f7d147 lazy load ocr dependency 2026-06-23 18:02:24 -05:00
Tyler 33eb0f745e semantic titles ai assist 2026-06-23 17:46:33 -05:00
TylerandGitHub cb5e0c6837 Merge pull request #2 from Twest2/text_locations
Honor text block positions in exports
2026-06-23 16:57:06 -05:00
Tyler ffaa123893 Honor text block positions in exports 2026-06-23 16:44:28 -05:00
Tyler 2b27428849 minor changes
Template tests / tests (push) Successful in 1m44s
2026-06-23 16:26:13 -05:00
Tyler 4cbab15429 Update README.md to point to new getting started documents
Template tests / tests (push) Successful in 1m42s
2026-06-23 12:18:40 -05:00
Tyler 063624ea57 Add and update TODO 2026-06-23 12:16:51 -05:00
Tyler 7fe30cf8ef Windows getting started docs with stepforge example
Template tests / tests (push) Successful in 1m44s
2026-06-23 12:10:42 -05:00
Tyler d607905b58 Disable electron-builder publish for Windows installer
Template tests / tests (push) Successful in 1m42s
2026-06-23 11:15:56 -05:00
Tyler 0f25d327c0 Disable electron-builder publish for Windows installer
Template tests / tests (push) Successful in 1m42s
2026-06-23 11:10:22 -05:00
Tyler 058582d7af Add Windows installer packaging
Template tests / tests (push) Successful in 1m40s
2026-06-23 11:01:43 -05:00
Tyler f4278d118c Add optional Windows code signing env handling
Template tests / tests (push) Successful in 1m48s
Signed-off-by: Twest2 <[email protected]>
2026-06-23 10:00:28 -05:00
Tyler c2247a57c7 Bulk documentation edits
Template tests / tests (push) Successful in 1m44s
2026-06-22 09:50:29 -05:00
Tyler d89abf5b78 Arm capture when creating a guide from the library
createGuide() opened the new guide with plain openGuide(), so no capture
session was armed — the "Start recording" bar never bound to the new guide,
and clicking it did nothing (or resumed a stale prior session). Every other
open path (guide cards, search results, New Capture) arms a paused session;
this aligns createGuide() with them via openGuideAndArmCapture().
2026-06-16 13:13:32 -05:00
Tyler 845b3ed205 Add GitHub Actions: CI and a manual release workflow
- ci.yml: runs the unit test suite (npm test → node --test tests/unit) on
  push and PR to main, across ubuntu/windows/macos with Node 20. Electron is
  installed normally because the capture unit tests require app/capture.js,
  which require()s the electron module at load.

- release.yml: manual workflow_dispatch. Enter a version tag (e.g. v0.1.1);
  it builds the Windows portable .exe (npm run package:windows) and the Linux
  tarball + .deb (scripts/package-linux.sh), stamps the requested version onto
  each artifact, then publishes a GitHub Release at the current commit with
  the artifacts attached and auto-generated notes. Supports a prerelease flag.
2026-06-16 12:39:19 -05:00
Tyler 3def598528 Tighten battery capture: cover lazily-spawned capture processes, widen warmup
Follow-up polish after the EcoQoS fix landed and recordings improved:

- Re-apply the EcoQoS opt-out when the stream backend reports it is active.
  Chromium spawns/upgrades its GPU and screen-capture utility processes only
  once the desktop stream actually starts — after the session-start sweep —
  so they could still be born throttled. Hooking the capture:state transition
  to 'stream' catches them the moment they appear.

- Raise the warmup cap from 1500ms to 3000ms. The recorder window stays
  visible during warmup (the user hasn't started their workflow yet) and the
  common path still proceeds the instant the stream is ready, so the extra
  headroom only matters when stream startup is slow on battery — where it
  buys a real pre-click frame for the very first click instead of a
  post-click fresh shot.
2026-06-16 08:15:46 -05:00
Tyler f580759337 Stop battery EcoQoS from starving frame capture during recording
Template tests / tests (push) Successful in 1m41s
Confirmed cause: on battery in a power-saving plan, Windows applies Power
Throttling (EcoQoS) to background work. StepForge records with its window
hidden, so the frame-capture worker renderer (plus the GPU and screen-capture
utility processes feeding it) get CPU-throttled. The throttled worker can't
sample the screen fast enough, so every click finds no fresh pre-click frame
and falls back to a slow post-click fresh shot — producing out-of-order,
dropped steps. It only reproduced on battery; plugged in (where EcoQoS stays
off even in eco mode) it worked. The capture log showed every click detected
but every one reporting "no frame qualified", with a ~4.6s stream startup.

Fixes:
- Chromium switches (disable-background-timer-throttling,
  disable-renderer-backgrounding, disable-backgrounding-occluded-windows) so
  Chromium stops de-prioritising and timer-throttling the hidden worker.
- New app/win-power.js opts the OS processes out of EcoQoS via
  SetProcessInformation(ProcessPowerThrottling, EXECUTION_SPEED off) and
  raises them to high priority. Applied to all live Electron processes when a
  session starts/resumes, and to the worker renderer the moment it is created
  (before it begins streaming). Best-effort, no-op off Windows.

The earlier mouse-hook EcoQoS opt-out already fixed click *detection*; this
fixes the frame-capture side that the same throttling was breaking.
2026-06-15 18:45:50 -05:00
Tyler 94f14851fa Keep mouse-hook process out of EcoQoS so clicks aren't missed in power-saving mode
Template tests / tests (push) Successful in 1m41s
The real cause of "only the first couple of clicks were captured while on
power saving mode": Windows Power Throttling (EcoQoS) CPU-starves background
processes under a power-saving plan. The low-level WH_MOUSE_LL hook lives in
the spawned PowerShell watcher; when its callback is starved past the system
LowLevelHooksTimeout, Windows silently stops delivering mouse events to it —
the process keeps running (so no exit/crash, no fallback to interval capture
fires), it just misses clicks, and those clicks never become steps.

The previous powerSaveBlocker('prevent-app-suspension') only maps to
SetThreadExecutionState, which blocks system sleep but does NOT opt a process
out of EcoQoS, so it couldn't fix this.

Have the watcher process opt itself out of execution-speed throttling via
SetProcessInformation(ProcessPowerThrottling, ...) and raise itself to
HIGH_PRIORITY_CLASS at startup, so the hook callback always runs at full
speed and every click is delivered regardless of the laptop's power mode.
2026-06-15 18:29:12 -05:00
Tyler 5edf740fda Fix capture degrading in eco/power-saving mode; decouple from display refresh rate
Template tests / tests (push) Successful in 1m47s
Two changes:
- Hold powerSaveBlocker.start('prevent-app-suspension') for the duration of
  each recording session. Windows Power Throttling (EcoQoS) can CPU-starve
  the capture-worker renderer mid-session, causing stream frame requests to
  time out and degrade to the slower in-process legacy loop. The blocker
  signals to the OS that this is an active workload and keeps the stream
  worker and main-process event loop running at full speed. Released on
  pause and finish.
- Remove maxFrameRate: 30 from the getUserMedia constraint in the capture
  worker. The actual sampling rate is driven entirely by the setInterval
  timer, so the stream's native frame rate is irrelevant to capture
  correctness. Capping at 30 could silently reduce stream delivery on some
  drivers when the display itself runs at ≤30 Hz (common in power-saving
  mode), leaving the ring buffer with fewer fresh frames at click time.
2026-06-15 18:20:57 -05:00
Tyler c39262c3fa Fix global placeholders save crashing with non-serializable error
Template tests / tests (push) Successful in 1m40s
The placeholders:globals:set IPC handler destructured { values } from
its args, but the renderer sends the placeholders object directly,
so values was always undefined and JSON.stringify(undefined) threw.
2026-06-15 18:08:16 -05:00
Tyler 46d0d622ca Redesign hotkey settings as press-to-record keycap widgets
Template tests / tests (push) Successful in 1m44s
Replace the raw accelerator-string text inputs in Settings > Capture
with a polished "click and press a key combination" control that
renders the shortcut as keycap chips with a clear button.
2026-06-15 17:59:08 -05:00
TylerandClaude Sonnet 4.6 204be178a3 Fix cramped spacing of the Quick Actions search hint
Template tests / tests (push) Successful in 1m42s
The "Type to search, arrows to move, Enter to open." hint had no
margin, so it sat flush against the search input above while the
results list below it had a 10px margin + 8px padding before its
divider - lopsided spacing. Make .quick-actions a flex column with a
uniform 8px gap so the hint sits evenly between the input and the
results divider.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-15 17:39:39 -05:00
TylerandClaude Sonnet 4.6 f37a2ec141 Fix DOCX table of contents to render real entries instead of a placeholder
Template tests / tests (push) Successful in 1m44s
Previously the "Contents" page just emitted a TOC field whose cached
result was the literal text "Update contents in Word" - so unless a
user manually ran Update Field in Word, the TOC showed nothing useful
(and many viewers never run that update at all).

Now each step heading is wrapped in a bookmark, and the TOC is built
as real TOC1/2/3-styled paragraphs with hyperlinks to those bookmarks
and PAGEREF fields for page numbers, matching the entry list other
exporters already produce via tocEntries(). The whole thing stays
wrapped in the original `TOC \o "1-3" \h \z \u` field so Word can still
refresh page numbers, but the document is correct on first open even
without that step.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-15 17:26:50 -05:00
Tyler a201b05c9e Merge pull request 'Add guide metadata, redesign PDF cover, paginate steps, and run exports in a helper process' (#19) from export_changes into main
Template tests / tests (push) Successful in 1m40s
Reviewed-on: Tyler/autodoc#19
2026-06-15 22:10:32 +00:00
TylerandClaude Sonnet 4.6 5b54305b9f Show a loading spinner on the Export/Preview buttons while exporting
The export dialog's Export and Preview buttons now disable and show a
spinner with status text while the corresponding async operation is
in flight, so the user knows the (sometimes slow) export is running
and the app isn't stuck.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-15 17:07:35 -05:00
Tyler 3de8ec978b Render PPTX callouts and normalize markdown text 2026-06-15 14:58:16 -05:00
Tyler 4f74b03324 fix docx toc and callouts 2026-06-15 14:46:39 -05:00
Tyler 7da39831ab remove markdown callout fill 2026-06-15 14:41:55 -05:00
Tyler ad3348f721 paginate pptx toc slides 2026-06-15 14:40:16 -05:00
Tyler d67afd2bc9 docx headings for toc 2026-06-15 14:37:02 -05:00
Tyler f79bbfed9f Style markdown callouts with HTML 2026-06-15 14:32:14 -05:00
Tyler 61e3c812a0 Polish exported documents and TOCs 2026-06-15 14:22:31 -05:00
Tyler b84b7883fe Fix export dialog format handling 2026-06-15 14:03:32 -05:00
Tyler b85eaad0cb Indent nested guide editor steps 2026-06-15 14:02:34 -05:00
Tyler 8cc1ba3532 Add Wiki.js markdown export option 2026-06-15 13:59:04 -05:00
Tyler ab280daf63 Style PDF cover and TOC links 2026-06-15 13:49:31 -05:00
Tyler b51a4c46ce Make PDF Contents entries clickable links to each step's page
Adds PdfBuilder.linkRect() for /Subtype /Link annotations with a
/Dest pointing at another page's destination. TOC entries record a
target placeholder that the per-step loop fills in once it knows
which page the step landed on, so clicking a Contents line jumps the
reader straight to that step.
2026-06-15 13:22:51 -05:00
Tyler 7229b154c9 Merge branch 'main' into export_changes 2026-06-15 13:11:35 -05:00
TylerandClaude Sonnet 4.6 722c5d2eee Add a Description field to the Guide information dialog
Reuses the guide's existing descriptionHtml/descriptionText, which
already renders on the PDF cover and at the top of every other export
format, so it surfaces alongside author/co-authors/organization with
no exporter changes needed.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-15 13:04:35 -05:00
TylerandClaude Sonnet 4.6 0b490e2aa9 Add guide metadata, redesign PDF cover, paginate steps per-page, and run exports in a helper process
Lets users record author/co-authors/organization for a guide via a new
"Guide information…" dialog; this metadata renders below the title on
the PDF cover (title now sits above the accent rule). PDF export also
paginates so each step fits its own page where possible, keeps a
step's title/image/lead-in together, and forces the next step onto a
fresh page after an oversized step overflows. Exports now run in a
forked helper process so large guides no longer freeze the UI.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-15 12:54:31 -05:00
Tyler e588f93a19 Merge pull request 'Mass guide editor changes' (#18) from guide_editor_changes into main
Template tests / tests (push) Successful in 1m39s
2026-06-15 15:37:48 +00:00
Tyler 4732c5daf3 Limit CI test run to main and manual triggers
Pushes to main (direct commits and merged PRs) and manual
workflow_dispatch now trigger the test job; feature-branch pushes and
PR open/sync no longer do.
2026-06-15 10:29:26 -05:00
Tyler db2382294e Document the Undo/Redo shortcut in the keyboard shortcuts help
Template tests / tests (push) Successful in 1m41s
Template tests / tests (pull_request) Successful in 1m40s
Now that undo also restores deleted steps, it's worth surfacing the
existing Ctrl+Z / Ctrl+Shift+Z shortcut to users.
2026-06-15 10:26:56 -05:00
Tyler 58b4182c54 Add global undo for step deletion; simplify capture session UI
Template tests / tests (push) Successful in 1m41s
Template tests / tests (pull_request) Successful in 1m39s
- The canvas Undo/Redo buttons now also undo/redo step deletion
  (single or multi-select), restoring the step's data, images, and
  position in the order. Backed by a new step:restore IPC call and
  GuideStore.restoreStep().
- Remove "Finish capture session" from the capture menu. The
  top-right recording bar in the guide editor is now the only place
  to start/stop recording, with its toggle relabeled
  "Start recording" / "Stop recording".
2026-06-15 10:15:15 -05:00
Tyler 88d1fc3efb Spell out "Highlight" in the annotation toolbar instead of "Hi"
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m44s
2026-06-15 09:50:34 -05:00
Tyler 2bb17b170e Use display labels for annotation types in the Annotations list and copy-style text
Template tests / tests (push) Successful in 1m40s
Template tests / tests (pull_request) Successful in 1m40s
The per-annotation list cards and the "Copy this style to every other
... annotation" text/tooltips were still showing raw type ids (rect,
arrow, etc). Use ANNOTATION_TYPE_LABELS for these too.
2026-06-15 09:46:31 -05:00
Tyler 6408040623 Show proper display names in the annotation Type dropdown
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m39s
The annotation editor's "Type" select used the raw lowercase type id
(e.g. "rect") as its label. Add an ANNOTATION_TYPE_LABELS map so it
shows "Rectangle", "Tooltip", "Number", etc. instead.
2026-06-15 09:43:09 -05:00
Tyler a3425c36b4 Spell out "Rectangle" in tool labels instead of "Rect"
Template tests / tests (push) Successful in 1m41s
Template tests / tests (pull_request) Successful in 1m39s
Keeps the internal 'rect' annotation type id (canvas/raster/schema)
unchanged; only updates the user-facing toolbar button and shortcuts
help text.
2026-06-15 09:38:24 -05:00
Tyler 5d4925dee4 Fix export block ordering, stale per-step UI, and cross-step block loss
Template tests / tests (push) Successful in 1m42s
Template tests / tests (pull_request) Successful in 1m41s
Exporters now interleave text/code/table blocks in the same order they
appear in the editor's Blocks panel (via a shared stepContentGroups
helper) instead of grouping by kind, so exported docs match the guide
editor's ordering.

selectStep() now also refreshes the Focused View controls and Blocks
panel (previously only done by renderAll), so switching steps no longer
leaves the previous step's blocks/focused-view sliders on screen. It
also flushes any pending edits on the outgoing step before switching, so
a later guide-wide reload (e.g. applying an annotation style to the
whole guide) can't discard unsaved text-block edits on other steps.
2026-06-14 09:11:00 -05:00
Tyler 06fba24645 Use GitHub-Flavored Markdown alert syntax for Note/Tip/Warning/Important blocks
Template tests / tests (push) Successful in 1m40s
Template tests / tests (pull_request) Successful in 1m40s
Markdown export now emits > [!NOTE]/[!TIP]/[!WARNING]/[!IMPORTANT] for
text-block callouts, giving them the same colored/icon-labeled treatment
as the PDF/HTML/DOCX exports on renderers that support GFM alerts
(GitHub, Azure DevOps wikis, etc.), while degrading gracefully to a
plain blockquote elsewhere.
2026-06-13 23:11:02 -05:00
TylerandClaude Sonnet 4.6 fee394f6b7 Add quote indicator and distinct callout styling for Note/Tip/Warning/Important
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m42s
- Description editor: blockquotes get a left accent line and muted color so
  it's clear when typing in "quote mode" (matches the existing active Quote
  toolbar button highlight).
- Editor block cards: each text block's left border is colored by its level
  (info/success/warn/error) so Note/Tip/Warning/Important are distinguishable
  at a glance.
- PDF/HTML/DOCX exports: callouts now get a level-specific accent color and
  tinted background/shading (blue/green/amber/red), instead of all looking
  identical except for the label text.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-13 23:02:03 -05:00
TylerandClaude Sonnet 4.6 f094bbbd73 Fix inflated word spacing in PDF description text
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m40s
writeDescription/emitBlocks rendered each word as a separately positioned
Tj, advancing by our approximate average-glyph-width estimate — which is
much wider than real space/character widths, producing "This  is  bold"
style gaps. Render each line as one continuous text object instead
(PdfBuilder.textRun), so consecutive Tj operators advance using the
viewer's real font metrics.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-13 21:42:26 -05:00
TylerandClaude Sonnet 4.6 0ead121327 Render rich text, links, and special characters correctly in PDF exports
Template tests / tests (push) Successful in 1m40s
Template tests / tests (pull_request) Successful in 1m39s
PDF exports now preserve bold/italic/links/lists/blockquotes from the
description editor (via a new HTML block/run parser) instead of flattening
to plain text. Literal "[text](url)" links inserted by the editor's Link
button are converted to real <a> tags before rendering. Tabs and common
"smart typography" characters (smart quotes, dashes, ellipsis, bullet) are
mapped to their WinAnsiEncoding glyphs instead of rendering as "?".

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-13 21:35:55 -05:00
Tyler 420b32c0f8 Description editor: insert link placeholder inline instead of prompting
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m40s
Clicking Link no longer pops up "Link text"/"Link URL" dialogs; it
inserts [Text](Link) (or [selectedText](Link)) directly so the
placeholders can be edited in place.
2026-06-13 21:11:58 -05:00
Tyler bb959a4bb9 Description editor: preserve newlines on export, toggle Quote, highlight active toolbar buttons
Template tests / tests (push) Successful in 1m49s
Template tests / tests (pull_request) Successful in 1m38s
- Enter now starts a new <p> (defaultParagraphSeparator) so line breaks
  in the description box show up as line breaks in HTML/Markdown/Confluence
  exports; htmlToMarkdown also handles <div>-separated paragraphs.
- Clicking Quote while already in a blockquote toggles back to a paragraph.
- Toolbar buttons (Bold, Italic, Bullet, Number, Quote) turn blue to
  reflect the formatting state at the current cursor/selection.
2026-06-13 21:08:31 -05:00
Tyler 46d12cc829 Insert markdown-style [text](url) links from the Link toolbar button
Template tests / tests (push) Successful in 1m40s
Template tests / tests (pull_request) Successful in 1m40s
Previously this wrapped the selection in a real <a> tag via
createLink. Now it inserts literal [text](url) markdown syntax,
using the selection as the link text or prompting for it if nothing
is selected.
2026-06-13 20:47:16 -05:00
Tyler 8e2e1f41fb Show "Make substep of…" as a hover submenu listing all steps
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m40s
Replaces the number-entry prompt with a scrollable side panel (so
large guides don't overflow the screen) listing every eligible step;
clicking one immediately re-parents the step. Context menus now
support submenu items in general via contextMenu().
2026-06-13 20:33:29 -05:00
Tyler a58607f029 Add a "Make substep of…" option to the step context menu
Template tests / tests (push) Successful in 1m40s
Template tests / tests (pull_request) Successful in 1m39s
Lets a step be re-parented under another step by entering that step's
displayed number, moving it (with its own substeps) to the end of the
target's substeps and updating the numbering accordingly.
2026-06-13 20:23:31 -05:00
Tyler be88434e44 Prompt for a name and save before creating a manual snapshot
Template tests / tests (push) Successful in 1m38s
Template tests / tests (pull_request) Successful in 1m42s
Clicking Snapshot now opens a named-prompt dialog (matching the app's
modal styling) and flushes the current step/guide first, so the
snapshot reflects unsaved edits and is easy to identify later.
2026-06-13 20:07:55 -05:00
Tyler 1776089c08 Remove the guide-link panel button; Save now syncs linked archives
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m39s
For linked guides, "Save now" now also writes back to the linked
.sfgz archive (reporting a lock conflict if another session holds it),
making the dedicated link/share button in the Guide panel redundant.
The toolbar Share button and More > Linked guide… menu still cover
one-off exports and archive management.
2026-06-13 19:50:22 -05:00
Tyler 31bdbea7b5 Replace the disabled "Local guide" label with a "Share as file" action
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m39s
The guide panel's link button was a dead disabled control for local
guides. It now shares the guide as a .sfgz archive instead, while
linked guides keep the existing archive-management dialog.
2026-06-13 19:38:05 -05:00
Tyler 9e88991f46 Clarify the style-copy buttons' scope with a heading and richer tooltips
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m40s
Renamed "Style → step/guide" to "This step"/"Entire guide" under a
heading naming the annotation type, with hover text spelling out that
it overwrites every matching annotation's style in that scope.
2026-06-13 19:14:30 -05:00
Tyler 014b92675d Show only relevant settings per annotation type, drop redundant delete button
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m40s
The annotation editor always rendered every style field regardless of
type and a "Delete annotation" button duplicating Delete/Backspace.
2026-06-13 18:37:24 -05:00
TylerandClaude Sonnet 4.6 b39fe553b2 Allow Backspace to delete the selected annotation
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m39s
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-13 18:24:33 -05:00
TylerandClaude Sonnet 4.6 602e70a7e1 Linearize focused-view pan to the available range and flip Pan Y
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m39s
panX/panY now represent 0..1 fractions of the pannable range rather
than absolute image positions, so the slider's full travel always
covers edge-to-edge regardless of zoom level. Pan Y is inverted so
sliding right moves the view up. Updated both the editor canvas and
core/raster.js export so they stay in sync.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-13 18:21:00 -05:00
TylerandClaude Sonnet 4.6 f23b49c0d1 Make the focused-view crop live in the editor canvas
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m40s
The annotation canvas now renders as a viewport into the focused-view
crop region, matching what core/raster.js produces on export. Sliders
update the canvas live; annotation data stays in full-image-normalized
coordinates and remains correctly positioned, sized, and editable.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-13 18:09:17 -05:00
80 changed files with 6958 additions and 977 deletions
+3 -5
View File
@@ -2,11 +2,9 @@ name: Template tests
on: on:
push: push:
pull_request: branches:
types: - main
- opened workflow_dispatch:
- synchronize
- reopened
jobs: jobs:
tests: tests:
-6
View File
@@ -7,12 +7,6 @@
- [ ] Git / workflow - [ ] Git / workflow
- [ ] Other - [ ] Other
## Issue Type
- [ ] Bug
- [ ] Improvement
- [ ] Task
- [ ] Documentation
## Summary ## Summary
+7 -1
View File
@@ -1,15 +1,21 @@
<!-- please delete the unused check box's on the creation of your pr -->
## Improvement Area ## Improvement Area
- [ ] Guide Editor
- [ ] Library
- [ ] Settings
- [ ] Exports
- [ ] Documentation - [ ] Documentation
- [ ] Tests - [ ] Tests
- [ ] Git / workflow - [ ] Git / workflow
- [ ] Other - [ ] Other
## Issue ## Issue
<!-- Replace the example with the issue number this PR resolves. -->
- Closes # - Closes #
<!-- Replace the example with the issue number this PR resolves. -->
## Summary ## Summary
+35
View File
@@ -0,0 +1,35 @@
name: CI
on:
push:
branches: [main]
# Cancel an in-progress run when newer commits are pushed to the same ref.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Unit tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
# The capture unit tests require app/capture.js, which require()s the
# electron module at load — so the Electron binary must actually be
# installed (don't set ELECTRON_SKIP_BINARY_DOWNLOAD here).
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test
+134
View File
@@ -0,0 +1,134 @@
name: Release
# Manual release: from the GitHub "Actions" tab pick "Release", click
# "Run workflow", enter the version tag, and it builds the Windows installer
# .exe and the Linux tarball + .deb, then publishes a GitHub Release with all
# artifacts attached and auto-generated notes.
on:
workflow_dispatch:
inputs:
version:
description: 'Release tag, e.g. v0.1.1 (the tag is created at the current commit on this branch)'
required: true
type: string
prerelease:
description: 'Mark this release as a pre-release'
required: false
default: false
type: boolean
# Needed for the release job to create the tag and the GitHub Release.
permissions:
contents: write
jobs:
build-windows:
name: Build Windows installer
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
# Stamp the requested version onto package.json so the produced artifact
# carries it. Not committed back — it only affects this build.
- name: Set version from input
shell: bash
env:
VERSION: ${{ inputs.version }}
run: npm version "${VERSION#v}" --no-git-tag-version --allow-same-version
- name: Configure Windows code signing
shell: bash
env:
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
run: |
if [ -n "$WIN_CSC_LINK" ] && [ -n "$WIN_CSC_KEY_PASSWORD" ]; then
{
printf 'WIN_CSC_LINK=%s\n' "$WIN_CSC_LINK"
printf 'WIN_CSC_KEY_PASSWORD=%s\n' "$WIN_CSC_KEY_PASSWORD"
} >> "$GITHUB_ENV"
else
echo "Windows code signing secrets not configured; building unsigned."
fi
- name: Package Windows installer .exe
run: npm run package:windows
- uses: actions/upload-artifact@v4
with:
name: windows-installer
path: releases/*.exe
if-no-files-found: error
build-linux:
name: Build Linux tarball + .deb
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Set version from input
shell: bash
env:
VERSION: ${{ inputs.version }}
run: npm version "${VERSION#v}" --no-git-tag-version --allow-same-version
- name: Package Linux artifacts (tarball + .deb)
shell: bash
env:
STEPFORGE_PACKAGE_DIR: ${{ github.workspace }}/build/artifacts
run: bash scripts/package-linux.sh
- uses: actions/upload-artifact@v4
with:
name: linux-artifacts
path: build/artifacts/*
if-no-files-found: error
release:
name: Publish GitHub Release
needs: [build-windows, build-linux]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
path: dist
- name: List downloaded artifacts
run: find dist -type f -printf '%s\t%p\n'
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.version }}
PRERELEASE: ${{ inputs.prerelease }}
run: |
flags=()
if [ "$PRERELEASE" = "true" ]; then
flags+=(--prerelease)
fi
gh release create "$TAG" \
dist/windows-installer/*.exe \
dist/linux-artifacts/* \
--title "StepForge $TAG" \
--target "${{ github.sha }}" \
--generate-notes \
"${flags[@]}"
+11 -26
View File
@@ -3,11 +3,10 @@
StepForge is a **fully offline**, open-source desktop app for Windows and StepForge is a **fully offline**, open-source desktop app for Windows and
Linux that captures step-by-step workflows as screenshots, lets you annotate Linux that captures step-by-step workflows as screenshots, lets you annotate
and describe each step in a focused three-pane editor, and exports the result and describe each step in a focused three-pane editor, and exports the result
to JSON, Markdown, HTML (simple and rich), PDF, animated GIF, image bundles, to Markdown, DOCX, PPTX, PDF, HTML (WIP), GIF (WIP), confluence (WIP), Wiki.js (WIP), and image bundles (WIP). The current reconmendations for exporting is Markdown and PDF.
DOCX, and PPTX.
It is an independent offline desktop guide-capture tool inspired by publicly It is an independent offline desktop guide-capture tool inspired by publicly
documented workflow patterns of commercial documentation tools. It contains no documented workflow patterns of commercial documentation tools like Folge. It contains no
third-party branding, assets, or code from those tools, and it never talks to third-party branding, assets, or code from those tools, and it never talks to
the network: no telemetry, no update checks, no license checks, no cloud, no the network: no telemetry, no update checks, no license checks, no cloud, no
remote AI. remote AI.
@@ -27,21 +26,6 @@ The core workflow:
4. **Export** — every exporter renders from the same normalized Render AST, 4. **Export** — every exporter renders from the same normalized Render AST,
so output is deterministic across formats. so output is deterministic across formats.
```text
┌────────────────────────────────────────────────────────────────────────────┐
│ StepForge > Reset Password SOP [Capture] [Export] [Save] │
├──────────────────┬──────────────────────────────────┬──────────────────────┤
│ Steps │ Canvas │ Properties │
│ 1 Open Users │ ┌────────────────────────────┐ │ Title │
│ 2 Search account │ │ [tooltip] │ │ [Reset password] │
│ 3 Click Reset │ │ ↘ ┌────────────┐ │ │ Description │
│ 3.1 Warning │ │ │ Reset btn │ │ │ [rich text editor] │
│ 4 Done │ │ └────────────┘ │ │ Text blocks │
│ [Add Step] │ └────────────────────────────┘ │ Step settings │
├──────────────────┴──────────────────────────────────┴──────────────────────┤
│ Tools: [Select][Rect][Oval][Line][Arrow][Text][Tooltip][#][Blur][Hi][Crop] │
└────────────────────────────────────────────────────────────────────────────┘
```
## What's Included ## What's Included
@@ -76,7 +60,7 @@ using only Node built-ins.
## Getting Started ## Getting Started
For a shorter walkthrough, see [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md). For a windows installation, see [docs/windows_installation](docs/windows_installation.md) or for a developer/more in depth walkthrough, see [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md).
Requirements: Node.js 20+ and npm (Electron is the only dependency). Requirements: Node.js 20+ and npm (Electron is the only dependency).
@@ -99,8 +83,8 @@ bash tests/run_test.sh
The runner executes every `tests/checks/test_*.sh` script; those scripts run The runner executes every `tests/checks/test_*.sh` script; those scripts run
the workflow test suites under `tests/unit/` with `node --test`. The tests the workflow test suites under `tests/unit/` with `node --test`. The tests
exercise real workflows creating guides, round-tripping archives, exporting exercise real workflows like creating guides, round-tripping archives, exporting
documents, and validating the bytes of the output not string matching. documents, and validating the bytes of the output, not string matching.
## Building & Packaging ## Building & Packaging
@@ -109,8 +93,8 @@ bash scripts/bootstrap-offline.sh # verify toolchain availability
bash scripts/verify.sh # full test suite + smoke checks bash scripts/verify.sh # full test suite + smoke checks
bash scripts/build-release.sh # assemble runnable app directory bash scripts/build-release.sh # assemble runnable app directory
bash scripts/package-linux.sh # portable tar.gz + .deb (+ AppDir spec) bash scripts/package-linux.sh # portable tar.gz + .deb (+ AppDir spec)
npm run package:windows # portable Windows .exe in releases/ npm run package:windows # Windows installer .exe in releases/
pwsh scripts/package-windows.ps1 # same Windows portable build via PowerShell pwsh scripts/package-windows.ps1 # same Windows installer build via PowerShell
``` ```
See [build/build_report.md](build/build_report.md) for what was produced on See [build/build_report.md](build/build_report.md) for what was produced on
@@ -131,11 +115,12 @@ clean-room rules.
## Repository Layout ## Repository Layout
Project docs live in `docs/`, and prompt handoffs live in `ai_prompts/`. Project docs live in `docs/` and prompt handoffs live in `ai_prompts/`.
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the repo layout. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the repo layout.
## License ## License
Application code is licensed under [MPL-2.0](LICENSE). Bundled example Creative Commons Attribution-NonCommercial
guides, templates, and screenshots are CC-BY-4.0 unless noted otherwise.
Basically, do whatever you want with it just don't make money off it or sell it.
+1
View File
@@ -0,0 +1 @@
In the future, planned updates include a re-vamp of the settings panel, text box's (important, tip, warning, and note) are not properly moving to the intended areas and I will look into locking the size (or being able to control the size) of images throughout varias export formats. OCR or local ai would be pretty cool....
+332 -27
View File
@@ -3,7 +3,6 @@
const path = require('node:path'); const path = require('node:path');
const { spawn, execFileSync } = require('node:child_process'); const { spawn, execFileSync } = require('node:child_process');
const { desktopCapturer, screen, BrowserWindow, nativeImage, Tray, Menu } = require('electron'); const { desktopCapturer, screen, BrowserWindow, nativeImage, Tray, Menu } = require('electron');
const { expandPlaceholders } = require('../core/placeholders');
const raster = require('../core/raster'); const raster = require('../core/raster');
const { encodePng } = require('../core/png'); const { encodePng } = require('../core/png');
const { const {
@@ -14,6 +13,7 @@ const {
DEFAULT_START_SLACK_MS, DEFAULT_START_SLACK_MS,
} = require('./click-frames'); } = require('./click-frames');
const { physicalToDip } = require('./coords'); const { physicalToDip } = require('./coords');
const { DEFAULT_CAPTURE_TITLES } = require('../core/text-intel');
/** /**
* Capture service: full-screen, active-window, and region capture, plus a * Capture service: full-screen, active-window, and region capture, plus a
@@ -56,9 +56,14 @@ const DEFAULT_CLICK_DEBOUNCE_MS = 200;
// representation that carries root coordinates) before firing without them. // representation that carries root coordinates) before firing without them.
const LINUX_CLICK_TWIN_MS = 25; const LINUX_CLICK_TWIN_MS = 25;
// Longest the window stays visible warming up the recorder at recording // Longest the window stays visible warming up the recorder at recording
// start. A slow capture-stream start (Windows can take several seconds) must // start. A slow capture-stream start (Windows can take several seconds,
// not keep the window up and recording un-armed indefinitely. // especially on battery) must not keep the window up and recording un-armed
const WARMUP_MAX_MS = 1500; // indefinitely — but the window is still visible during warmup, so the user
// hasn't begun their workflow yet, and the common case still proceeds the
// instant the stream is ready. The cap only bites when startup is slow, where
// a little more headroom buys a pre-click frame for the very first click
// instead of a post-click fresh shot.
const WARMUP_MAX_MS = 3000;
// Idle gap between legacy frame-loop grabs. Must stay well above zero: // Idle gap between legacy frame-loop grabs. Must stay well above zero:
// grabbing back-to-back starves the main-process event loop, which delays // grabbing back-to-back starves the main-process event loop, which delays
// delivery of click events from the OS watcher by whole seconds. (The // delivery of click events from the OS watcher by whole seconds. (The
@@ -109,6 +114,7 @@ class CaptureService {
getWindow, getWindow,
notify, notify,
screenApi = screen, screenApi = screen,
textIntel = null,
}) { }) {
this.store = store; this.store = store;
this.settings = settings; this.settings = settings;
@@ -117,6 +123,7 @@ class CaptureService {
// Injectable for tests; the click/coordinate paths must never reach for // Injectable for tests; the click/coordinate paths must never reach for
// the global `screen` directly so coordinate handling stays testable. // the global `screen` directly so coordinate handling stays testable.
this.screen = screenApi; this.screen = screenApi;
this.textIntel = textIntel;
this.session = null; // { guideId, paused, count, intervalSec } this.session = null; // { guideId, paused, count, intervalSec }
this.intervalTimer = null; this.intervalTimer = null;
this.clickWatcher = null; this.clickWatcher = null;
@@ -128,6 +135,11 @@ class CaptureService {
this.clickWatcherErrTail = ''; this.clickWatcherErrTail = '';
this.linuxEvent = null; // event block currently being parsed this.linuxEvent = null; // event block currently being parsed
this.pendingRawClick = null; // raw press waiting for its coordinate twin this.pendingRawClick = null; // raw press waiting for its coordinate twin
this.pendingClickOsPoint = null;
this._keyBuffer = ''; // printable chars typed since last capture
this._lastShortcut = ''; // last Ctrl+X / Alt+X combination
this._keyLastAt = 0; // timestamp of last key event
this._pendingWindowContext = null; // buffered CTX+ELEM from click watcher, consumed on CLICK
this.clickQueue = Promise.resolve(); this.clickQueue = Promise.resolve();
this.frameLoopInFlight = false; this.frameLoopInFlight = false;
this.frameLoopGrabStartedAt = null; this.frameLoopGrabStartedAt = null;
@@ -458,7 +470,7 @@ class CaptureService {
if (frame) { if (frame) {
clog('click@', clickAt, 'frame', frame.source || 'loop', clog('click@', clickAt, 'frame', frame.source || 'loop',
'started', frame.startedAt - clickAt, 'ms, captured', frame.capturedAt - clickAt, 'ms rel. click'); 'started', frame.startedAt - clickAt, 'ms, captured', frame.capturedAt - clickAt, 'ms rel. click');
const result = this.storeFrameAsStep(guideId, frame.mode, frame, clickPos); const result = await this.storeFrameAsStep(guideId, frame.mode, frame, clickPos, clickMeta);
if (result.ok) this.noteStepAdded(result.step, trigger, guideId); if (result.ok) this.noteStepAdded(result.step, trigger, guideId);
return result; return result;
} }
@@ -791,16 +803,31 @@ using System.Collections.Concurrent;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading; using System.Threading;
public static class SFMouseHook { public static class SFHook {
private const int WH_MOUSE_LL = 14; private const int WH_MOUSE_LL = 14;
private const int WH_KEYBOARD_LL = 13;
private const int WM_LBUTTONDOWN = 0x0201; private const int WM_LBUTTONDOWN = 0x0201;
private const int WM_RBUTTONDOWN = 0x0204; private const int WM_RBUTTONDOWN = 0x0204;
private const int WM_MBUTTONDOWN = 0x0207; private const int WM_MBUTTONDOWN = 0x0207;
private const int WM_XBUTTONDOWN = 0x020B; private const int WM_XBUTTONDOWN = 0x020B;
private const int WM_KEYDOWN = 0x0100;
private const int WM_SYSKEYDOWN = 0x0104;
private const long UnixEpochMilliseconds = 62135596800000L; private const long UnixEpochMilliseconds = 62135596800000L;
// Opting this process out of Windows Power Throttling (EcoQoS). In a
// power-saving plan the OS CPU-starves background processes; a starved
// low-level mouse hook whose callback exceeds LowLevelHooksTimeout is
// silently skipped by Windows (events stop arriving) while the process
// stays alive, so clicks are missed with no error — the "only the first
// couple of clicks were captured" symptom.
private const int ProcessPowerThrottling = 4;
private const uint PROCESS_POWER_THROTTLING_CURRENT_VERSION = 1;
private const uint PROCESS_POWER_THROTTLING_EXECUTION_SPEED = 0x1;
private const uint HIGH_PRIORITY_CLASS = 0x00000080;
private static IntPtr hook = IntPtr.Zero; private static IntPtr hook = IntPtr.Zero;
private static LowLevelMouseProc proc = HookCallback; private static IntPtr keyHook = IntPtr.Zero;
private static LowLevelMouseProc proc = MouseHookCallback;
private static LowLevelKeyboardProc keyProc = KeyboardHookCallback;
private static readonly ConcurrentQueue<string> queue = new ConcurrentQueue<string>(); private static readonly ConcurrentQueue<string> queue = new ConcurrentQueue<string>();
private static readonly AutoResetEvent signal = new AutoResetEvent(false); private static readonly AutoResetEvent signal = new AutoResetEvent(false);
@@ -829,11 +856,41 @@ public static class SFMouseHook {
public POINT pt; public POINT pt;
} }
[StructLayout(LayoutKind.Sequential)]
private struct PROCESS_POWER_THROTTLING_STATE {
public uint Version;
public uint ControlMask;
public uint StateMask;
}
[StructLayout(LayoutKind.Sequential)]
private struct KBDLLHOOKSTRUCT {
public uint vkCode;
public uint scanCode;
public uint flags;
public uint time;
public UIntPtr dwExtraInfo;
}
private delegate IntPtr LowLevelMouseProc(int nCode, IntPtr wParam, IntPtr lParam); private delegate IntPtr LowLevelMouseProc(int nCode, IntPtr wParam, IntPtr lParam);
private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll", SetLastError = true)] [DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelMouseProc lpfn, IntPtr hMod, uint dwThreadId); private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelMouseProc lpfn, IntPtr hMod, uint dwThreadId);
[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);
[DllImport("user32.dll")]
private static extern short GetKeyState(int nVirtKey);
[DllImport("user32.dll")]
private static extern bool GetKeyboardState([Out] byte[] lpKeyState);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int ToUnicode(uint wVirtKey, uint wScanCode, byte[] lpKeyState,
System.Text.StringBuilder pwszBuff, int cchBuff, uint wFlags);
[DllImport("user32.dll", SetLastError = true)] [DllImport("user32.dll", SetLastError = true)]
private static extern bool UnhookWindowsHookEx(IntPtr hhk); private static extern bool UnhookWindowsHookEx(IntPtr hhk);
@@ -855,7 +912,85 @@ public static class SFMouseHook {
[DllImport("user32.dll")] [DllImport("user32.dll")]
private static extern bool SetProcessDpiAwarenessContext(IntPtr value); private static extern bool SetProcessDpiAwarenessContext(IntPtr value);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool SetProcessInformation(IntPtr hProcess, int ProcessInformationClass, ref PROCESS_POWER_THROTTLING_STATE ProcessInformation, uint ProcessInformationSize);
[DllImport("kernel32.dll")]
private static extern IntPtr GetCurrentProcess();
[DllImport("kernel32.dll")]
private static extern bool SetPriorityClass(IntPtr hProcess, uint dwPriorityClass);
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder text, int count);
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr OpenProcess(uint processAccess, bool bInheritHandle, uint processId);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr hObject);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool QueryFullProcessImageName(IntPtr hProcess, uint dwFlags, System.Text.StringBuilder lpExeName, ref uint lpdwSize);
private static string GetFwTitle() {
try {
IntPtr hwnd = GetForegroundWindow();
if (hwnd == IntPtr.Zero) return "";
var sb = new System.Text.StringBuilder(512);
GetWindowText(hwnd, sb, sb.Capacity);
return sb.ToString();
} catch { return ""; }
}
private static string GetFwApp() {
try {
IntPtr hwnd = GetForegroundWindow();
if (hwnd == IntPtr.Zero) return "";
uint pid = 0;
GetWindowThreadProcessId(hwnd, out pid);
if (pid == 0) return "";
IntPtr hProc = OpenProcess(0x1000u, false, pid);
if (hProc == IntPtr.Zero) return "";
var sb = new System.Text.StringBuilder(260);
uint sz = (uint)sb.Capacity;
QueryFullProcessImageName(hProc, 0u, sb, ref sz);
CloseHandle(hProc);
string path = sb.ToString();
return string.IsNullOrEmpty(path) ? "" : System.IO.Path.GetFileNameWithoutExtension(path);
} catch { return ""; }
}
// Base64-encodes a string for safe line-protocol transmission; "-" for empty.
private static string B64(string s) {
if (string.IsNullOrEmpty(s)) return "-";
try { return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(s)); } catch { return "-"; }
}
// Force this process to run at full CPU speed regardless of the power plan,
// so the mouse-hook callback never trips LowLevelHooksTimeout and clicks
// keep being delivered while the laptop is in eco / power-saving mode.
private static void KeepProcessResponsive() {
try {
PROCESS_POWER_THROTTLING_STATE state = new PROCESS_POWER_THROTTLING_STATE();
state.Version = PROCESS_POWER_THROTTLING_CURRENT_VERSION;
// Control EXECUTION_SPEED and set its state bit to 0 => throttling off
// (the documented way to opt a process out of EcoQoS).
state.ControlMask = PROCESS_POWER_THROTTLING_EXECUTION_SPEED;
state.StateMask = 0;
SetProcessInformation(GetCurrentProcess(), ProcessPowerThrottling, ref state, (uint)Marshal.SizeOf(state));
} catch { }
try { SetPriorityClass(GetCurrentProcess(), HIGH_PRIORITY_CLASS); } catch { }
}
public static void Run() { public static void Run() {
KeepProcessResponsive();
try { SetProcessDpiAwarenessContext(new IntPtr(-4)); } catch { } try { SetProcessDpiAwarenessContext(new IntPtr(-4)); } catch { }
Thread writer = new Thread(WriterLoop); Thread writer = new Thread(WriterLoop);
@@ -866,6 +1001,8 @@ public static class SFMouseHook {
if (hook == IntPtr.Zero) { if (hook == IntPtr.Zero) {
throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
} }
// Keyboard hook is optional — if it fails we still get click titles.
try { keyHook = SetWindowsHookEx(WH_KEYBOARD_LL, keyProc, GetModuleHandle(null), 0); } catch { }
Console.Out.WriteLine("READY"); Console.Out.WriteLine("READY");
Console.Out.Flush(); Console.Out.Flush();
@@ -877,6 +1014,7 @@ public static class SFMouseHook {
} }
UnhookWindowsHookEx(hook); UnhookWindowsHookEx(hook);
if (keyHook != IntPtr.Zero) UnhookWindowsHookEx(keyHook);
} }
private static void WriterLoop() { private static void WriterLoop() {
@@ -890,13 +1028,19 @@ public static class SFMouseHook {
} }
} }
private static IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam) { private static IntPtr MouseHookCallback(int nCode, IntPtr wParam, IntPtr lParam) {
if (nCode >= 0) { if (nCode >= 0) {
int message = wParam.ToInt32(); int message = wParam.ToInt32();
string button = ButtonName(message, lParam); string button = ButtonName(message, lParam);
if (button != null) { if (button != null) {
MSLLHOOKSTRUCT data = (MSLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(MSLLHOOKSTRUCT)); MSLLHOOKSTRUCT data = (MSLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(MSLLHOOKSTRUCT));
long unixMs = DateTime.UtcNow.Ticks / TimeSpan.TicksPerMillisecond - UnixEpochMilliseconds; long unixMs = DateTime.UtcNow.Ticks / TimeSpan.TicksPerMillisecond - UnixEpochMilliseconds;
// Capture window context while the user's app is still in foreground.
// GetForegroundWindow is synchronous and fast (no IPC overhead).
try {
string t = GetFwTitle(), a = GetFwApp();
queue.Enqueue("CTX " + B64(t) + " " + B64(a) + " " + unixMs);
} catch { }
queue.Enqueue("CLICK " + data.pt.x + " " + data.pt.y + " " + button + " " + unixMs); queue.Enqueue("CLICK " + data.pt.x + " " + data.pt.y + " " + button + " " + unixMs);
signal.Set(); signal.Set();
} }
@@ -904,6 +1048,75 @@ public static class SFMouseHook {
return CallNextHookEx(hook, nCode, wParam, lParam); return CallNextHookEx(hook, nCode, wParam, lParam);
} }
private static IntPtr KeyboardHookCallback(int nCode, IntPtr wParam, IntPtr lParam) {
if (nCode >= 0 && (wParam.ToInt32() == WM_KEYDOWN || wParam.ToInt32() == WM_SYSKEYDOWN)) {
try {
KBDLLHOOKSTRUCT kb = (KBDLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(KBDLLHOOKSTRUCT));
int vk = (int)kb.vkCode;
bool ctrl = (GetKeyState(0x11) & 0x8000) != 0;
bool alt = (GetKeyState(0x12) & 0x8000) != 0;
bool shift = (GetKeyState(0x10) & 0x8000) != 0;
// Skip standalone modifier keys.
bool isMod = vk == 0x10 || vk == 0x11 || vk == 0x12 || vk == 0x5B || vk == 0x5C;
if (!isMod) {
long unixMs = DateTime.UtcNow.Ticks / TimeSpan.TicksPerMillisecond - UnixEpochMilliseconds;
if (ctrl || alt) {
// Emit shortcut: "KEY Ctrl+T 123456"
string name = VkName(vk);
if (name != null) {
string mods = (ctrl ? "Ctrl" : "") + (ctrl && (alt || shift) ? "+" : "") +
(alt ? "Alt" : "") + ((alt || ctrl) && shift ? "+" : "") +
(shift ? "Shift" : "");
queue.Enqueue("KEY " + mods + "+" + name + " " + unixMs);
signal.Set();
}
} else if (vk == 0x08) {
queue.Enqueue("KEY Backspace " + unixMs); signal.Set();
} else if (vk == 0x1B) {
queue.Enqueue("KEY Escape " + unixMs); signal.Set();
} else if (vk == 0x0D) {
queue.Enqueue("KEY Enter " + unixMs); signal.Set();
} else {
// Map to Unicode character using current keyboard layout + shift state.
byte[] ks = new byte[256];
GetKeyboardState(ks);
var sb = new System.Text.StringBuilder(4);
int res = ToUnicode(kb.vkCode, kb.scanCode, ks, sb, 4, 0);
if (res > 0) {
char ch = sb[0];
if (ch >= 0x20 && ch < 0x7F) {
queue.Enqueue("CHAR " + (int)ch + " " + unixMs);
signal.Set();
}
}
}
}
} catch { }
}
return CallNextHookEx(keyHook, nCode, wParam, lParam);
}
private static string VkName(int vk) {
if (vk >= 0x41 && vk <= 0x5A) return ((char)vk).ToString(); // A-Z
if (vk >= 0x30 && vk <= 0x39) return ((char)vk).ToString(); // 0-9
if (vk >= 0x70 && vk <= 0x87) return "F" + (vk - 0x6F); // F1-F24
switch (vk) {
case 0x09: return "Tab";
case 0x0D: return "Enter";
case 0x1B: return "Escape";
case 0x20: return "Space";
case 0x21: return "PageUp"; case 0x22: return "PageDown";
case 0x23: return "End"; case 0x24: return "Home";
case 0x25: return "Left"; case 0x26: return "Up";
case 0x27: return "Right"; case 0x28: return "Down";
case 0x2E: return "Delete";
case 0x6B: case 0xBB: return "Plus";
case 0x6D: case 0xBD: return "Minus";
case 0xBE: return "Period"; case 0xBF: return "Slash";
default: return null;
}
}
private static string ButtonName(int message, IntPtr lParam) { private static string ButtonName(int message, IntPtr lParam) {
if (message == WM_LBUTTONDOWN) return "left"; if (message == WM_LBUTTONDOWN) return "left";
if (message == WM_RBUTTONDOWN) return "right"; if (message == WM_RBUTTONDOWN) return "right";
@@ -917,7 +1130,7 @@ public static class SFMouseHook {
} }
} }
'@ '@
[SFMouseHook]::Run() [SFHook]::Run()
`; `;
this.clickWatcher = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', ps], { this.clickWatcher = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', ps], {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
@@ -1052,11 +1265,33 @@ public static class SFMouseHook {
} }
if (platform === 'win32') { if (platform === 'win32') {
for (const line of lines) { for (const line of lines) {
const m = /^CLICK(?:\s+(-?\d+)\s+(-?\d+)(?:\s+([A-Za-z0-9_-]+))?(?:\s+(\d+))?)?\s*$/.exec(line.trim()); const trimmed = line.trim();
if (m) { const clickM = /^CLICK(?:\s+(-?\d+)\s+(-?\d+)(?:\s+([A-Za-z0-9_-]+))?(?:\s+(\d+))?)?\s*$/.exec(trimmed);
const osPoint = m[1] === undefined ? null : { x: Number(m[1]), y: Number(m[2]) }; if (clickM) {
const eventAt = m[4] === undefined ? Date.now() : Number(m[4]); const osPoint = clickM[1] === undefined ? null : { x: Number(clickM[1]), y: Number(clickM[2]) };
this.onOsClick(Number.isFinite(eventAt) ? eventAt : Date.now(), osPoint, m[3] || 'mouse'); const eventAt = clickM[4] === undefined ? Date.now() : Number(clickM[4]);
this.onOsClick(Number.isFinite(eventAt) ? eventAt : Date.now(), osPoint, clickM[3] || 'mouse');
continue;
}
const keyM = /^KEY\s+(\S+)\s+(\d+)\s*$/.exec(trimmed);
if (keyM) {
this.onKeyboardEvent('KEY', keyM[1], Number(keyM[2]));
continue;
}
const charM = /^CHAR\s+(\d+)\s+(\d+)\s*$/.exec(trimmed);
if (charM) {
this.onKeyboardEvent('CHAR', Number(charM[1]), Number(charM[2]));
continue;
}
// CTX is emitted just before its paired CLICK from MouseHookCallback.
const ctxM = /^CTX\s+(\S+)\s+(\S+)\s+\d+$/.exec(trimmed);
if (ctxM) {
const decode = s => s === '-' ? '' : Buffer.from(s, 'base64').toString('utf8');
this._pendingWindowContext = {
windowTitle: decode(ctxM[1]),
appName: decode(ctxM[2]),
};
continue;
} }
} }
} }
@@ -1163,6 +1398,9 @@ public static class SFMouseHook {
let clickPos = osPoint ? this.osPointToDip(osPoint) : null; let clickPos = osPoint ? this.osPointToDip(osPoint) : null;
if (!clickPos) clickPos = this.screen.getCursorScreenPoint(); if (!clickPos) clickPos = this.screen.getCursorScreenPoint();
clog('click@', clickAt, button, 'os', osPoint, '-> dip', clickPos); clog('click@', clickAt, button, 'os', osPoint, '-> dip', clickPos);
this.pendingClickOsPoint = osPoint && Number.isFinite(osPoint.x) && Number.isFinite(osPoint.y)
? { x: osPoint.x, y: osPoint.y }
: null;
this.enqueueClickCapture(clickPos, clickAt, button || 'mouse'); this.enqueueClickCapture(clickPos, clickAt, button || 'mouse');
} }
@@ -1220,7 +1458,24 @@ public static class SFMouseHook {
* fast the user clicks or how slow the encoder is. * fast the user clicks or how slow the encoder is.
*/ */
enqueueClickCapture(clickPos, clickAt = Date.now(), button = 'mouse') { enqueueClickCapture(clickPos, clickAt = Date.now(), button = 'mouse') {
const clickMeta = { at: Number.isFinite(clickAt) ? clickAt : Date.now(), button: button || 'mouse' }; const osPoint = this.pendingClickOsPoint && Number.isFinite(this.pendingClickOsPoint.x) && Number.isFinite(this.pendingClickOsPoint.y)
? this.pendingClickOsPoint
: null;
this.pendingClickOsPoint = null;
const clickMeta = {
at: Number.isFinite(clickAt) ? clickAt : Date.now(),
button: button || 'mouse',
};
if (osPoint) {
clickMeta.osPoint = { x: osPoint.x, y: osPoint.y };
}
// Snapshot keyboard context accumulated since the last capture.
clickMeta.keyContext = this.snapshotKeyContext();
// Attach the window + element context emitted by the click watcher.
if (this._pendingWindowContext) {
clickMeta.windowContext = this._pendingWindowContext;
this._pendingWindowContext = null;
}
if (this.session && !this.session.paused && !this.userIsInApp()) { if (this.session && !this.session.paused && !this.userIsInApp()) {
// The guide id pins the click to its recording so it can still be // The guide id pins the click to its recording so it can still be
// stored if the session stops while this click waits in the queue. // stored if the session stops while this click waits in the queue.
@@ -1234,6 +1489,46 @@ public static class SFMouseHook {
return this.clickQueue; return this.clickQueue;
} }
// --- Keyboard context tracking -------------------------------------------
onKeyboardEvent(type, data, eventAt) {
const now = Number.isFinite(eventAt) ? eventAt : Date.now();
// Discard stale typing that happened more than 8 seconds ago (user moved on).
if (now - this._keyLastAt > 8000) {
this._keyBuffer = '';
}
this._keyLastAt = now;
if (type === 'CHAR') {
const ch = typeof data === 'number' ? String.fromCharCode(data) : String(data);
this._keyBuffer = (this._keyBuffer + ch).slice(-200);
} else if (type === 'KEY') {
const key = String(data);
if (key === 'Backspace') {
this._keyBuffer = this._keyBuffer.slice(0, -1);
} else if (key === 'Escape') {
this._keyBuffer = '';
} else if (key === 'Enter') {
// Keep typed text — Enter often submits what was typed.
} else {
// It's a modifier+key shortcut (Ctrl+T etc.).
this._lastShortcut = key;
this._keyBuffer = ''; // a shortcut resets the typed-text buffer
}
}
}
snapshotKeyContext() {
const ctx = {
recentTyped: this._keyBuffer.trim(),
recentShortcut: this._lastShortcut,
};
// Reset after snapshot so each step gets its own context.
this._keyBuffer = '';
this._lastShortcut = '';
return ctx;
}
async captureCurrentFrame(mode, capturePoint = null, startedAt = Date.now()) { async captureCurrentFrame(mode, capturePoint = null, startedAt = Date.now()) {
const grabbed = await this.grab(mode, capturePoint); const grabbed = await this.grab(mode, capturePoint);
return { return {
@@ -1252,7 +1547,18 @@ public static class SFMouseHook {
}; };
} }
storeFrameAsStep(guideId, mode, frame, clickPos = null) { async buildStepMeta(mode, frame, clickPos = null, clickMeta = null) {
try {
if (this.textIntel && typeof this.textIntel.buildCaptureContext === 'function') {
return await this.textIntel.buildCaptureContext({ mode, frame, clickPos, clickMeta });
}
} catch {
// fall back
}
return { title: this.autoTitle(mode), captureMetadata: null };
}
async storeFrameAsStep(guideId, mode, frame, clickPos = null, clickMeta = null) {
if (!frame) return { ok: false, reason: 'no capture frame available' }; if (!frame) return { ok: false, reason: 'no capture frame available' };
const annotations = []; const annotations = [];
// The click position (DIP, read at event time) wins over the frame's // The click position (DIP, read at event time) wins over the frame's
@@ -1275,8 +1581,10 @@ public static class SFMouseHook {
} }
} }
const { title, captureMetadata } = await this.buildStepMeta(mode, frame, clickPos, clickMeta);
const step = this.store.addStep(guideId, { const step = this.store.addStep(guideId, {
title: this.autoTitle(mode), title,
captureMetadata,
annotations, annotations,
focusedView: { focusedView: {
enabled: Boolean(this.settings.get('editor.focusedViewDefaultForNewSteps')), enabled: Boolean(this.settings.get('editor.focusedViewDefaultForNewSteps')),
@@ -1287,14 +1595,7 @@ public static class SFMouseHook {
} }
autoTitle(mode) { autoTitle(mode) {
const tplStr = this.settings.get('editor.autoTitleTemplate') || '[[Mode]] capture [[Time]]'; return DEFAULT_CAPTURE_TITLES[mode] || 'Capture';
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
return expandPlaceholders(tplStr, {
Mode: { fullscreen: 'Screen', window: 'Window', region: 'Region' }[mode] || 'Screen',
Time: `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`,
Date: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`,
});
} }
/** Grab the screen/window image as { image, display } or throw. */ /** Grab the screen/window image as { image, display } or throw. */
@@ -1403,8 +1704,12 @@ public static class SFMouseHook {
const cropped = image.crop(rect); const cropped = image.crop(rect);
const size = cropped.getSize(); const size = cropped.getSize();
if (!size.width || !size.height) return { ok: false, reason: 'empty selection' }; if (!size.width || !size.height) return { ok: false, reason: 'empty selection' };
const step = this.store.addStep(guideId, { title: this.autoTitle('region') }, const step = await this.storeFrameAsStep(guideId, 'region', {
cropped.toPNG(), size); image: cropped,
size,
display,
cursor: null,
}, null, null);
return { ok: true, step }; return { ok: true, step };
} }
+42
View File
@@ -0,0 +1,42 @@
'use strict';
const path = require('node:path');
const { fork } = require('node:child_process');
const WORKER_PATH = path.join(__dirname, '..', 'core', 'export-worker.js');
/**
* Run an export job in a forked helper process, so building the render AST
* and rendering the output (e.g. a multi-step PDF) never blocks the main
* process — and therefore never freezes the editor window — no matter how
* large the guide is.
*/
function runExportInWorker({ dataDir, guideId, format, options, outDir, globals, maxSteps }) {
return new Promise((resolve, reject) => {
const worker = fork(WORKER_PATH, [], {
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
});
let settled = false;
const finish = (fn, value) => {
if (settled) return;
settled = true;
worker.removeAllListeners();
worker.kill();
fn(value);
};
worker.once('message', (msg) => {
if (msg && msg.ok) finish(resolve, msg.result);
else finish(reject, new Error((msg && msg.error) || 'Export worker failed.'));
});
worker.once('error', (err) => finish(reject, err));
worker.once('exit', (code) => {
finish(reject, new Error(`Export worker exited unexpectedly (code ${code}).`));
});
worker.send({ dataDir, guideId, format, options, outDir, globals, maxSteps });
});
}
module.exports = { runExportInWorker };
+179 -14
View File
@@ -5,19 +5,40 @@ const fs = require('node:fs');
const os = require('node:os'); const os = require('node:os');
const { const {
app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, globalShortcut, app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, globalShortcut,
clipboard, nativeImage, screen, clipboard, nativeImage, screen, powerSaveBlocker,
} = require('electron'); } = require('electron');
const { GuideStore } = require('../core/store'); const { GuideStore } = require('../core/store');
const { Settings } = require('../core/settings'); const { Settings } = require('../core/settings');
const { SearchIndex } = require('../core/search'); const { SearchIndex } = require('../core/search');
const { TemplateManager, FORMATS } = require('../core/templates'); const { TemplateManager, FORMATS, FORMAT_LABELS } = require('../core/templates');
const { buildRenderAst } = require('../core/renderast'); const { buildRenderAst } = require('../core/renderast');
const { runExport, EXPORTERS } = require('../exporters'); const { runExport, EXPORTERS } = require('../exporters');
const { runExportInWorker } = require('./export-runner');
const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } = require('../core/archive'); const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } = require('../core/archive');
const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots'); const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots');
const { readLock } = require('../core/locks'); const { readLock } = require('../core/locks');
const CaptureService = require('./capture'); const CaptureService = require('./capture');
const { TextIntelService } = require('./text-intel');
const { keepProcessesResponsive } = require('./win-power');
const APP_ID = 'com.stepforge.app';
if (process.platform === 'win32') {
app.setAppUserModelId(APP_ID);
}
// Keep capture working on battery. In a power-saving plan on DC power, Windows
// applies Power Throttling (EcoQoS) to background work — and StepForge records
// with its window hidden, so the frame-capture worker renderer is exactly the
// kind of "background" process the OS slows down. A throttled worker can't
// sample the screen fast enough, so every click finds no fresh frame and the
// recording falls apart (the bug only ever reproduced on battery). These
// switches stop Chromium from de-prioritising and timer-throttling the hidden
// worker; win-power.js additionally opts the OS processes out of EcoQoS.
app.commandLine.appendSwitch('disable-background-timer-throttling');
app.commandLine.appendSwitch('disable-renderer-backgrounding');
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows');
/** /**
* StepForge main process. Zero network code: no telemetry, no updates, no * StepForge main process. Zero network code: no telemetry, no updates, no
@@ -39,6 +60,7 @@ let settings;
let searchIndex; let searchIndex;
let templates; let templates;
let capture; let capture;
let textIntel;
let mainWindow; let mainWindow;
function reindex(guideId) { function reindex(guideId) {
@@ -419,6 +441,15 @@ function setupIpc() {
reindex(guideId); reindex(guideId);
return true; return true;
}); });
h('step:restore', ({ guideId, step, originalBase64, workingBase64, position }) => {
const images = {
original: originalBase64 ? Buffer.from(originalBase64, 'base64') : null,
working: workingBase64 ? Buffer.from(workingBase64, 'base64') : null,
};
const restored = store.restoreStep(guideId, step, images, position);
reindex(guideId);
return restored;
});
h('steps:reorder', ({ guideId, order }) => store.reorderSteps(guideId, order)); h('steps:reorder', ({ guideId, order }) => store.reorderSteps(guideId, order));
h('step:imagePath', ({ guideId, stepId, which }) => { h('step:imagePath', ({ guideId, stepId, which }) => {
const p = store.stepImagePath(guideId, stepId, which || 'working'); const p = store.stepImagePath(guideId, stepId, which || 'working');
@@ -474,26 +505,107 @@ function setupIpc() {
if (keyPath.startsWith('capture.hotkey')) registerHotkeys(); if (keyPath.startsWith('capture.hotkey')) registerHotkeys();
return settings.data; return settings.data;
}); });
h('ai:test', async ({ enabled = null, ollama = null } = {}) => {
return textIntel.testAiConnection({
enabled,
ollama,
});
});
h('ai:fillStep', async ({ guideId, stepId, target = 'all', blockId = null } = {}) => {
const result = await textIntel.generateStepPatch({
guideId,
stepId,
target,
blockId,
});
if (result.ok) reindex(guideId);
return result;
});
h('ai:rewriteText', async ({ text, guideTitle = '', stepTitle = '' } = {}) => {
return textIntel.rewriteText({ text, guideTitle, stepTitle });
});
h('placeholders:globals:get', () => settings.getGlobalPlaceholders()); h('placeholders:globals:get', () => settings.getGlobalPlaceholders());
h('placeholders:globals:set', ({ values }) => settings.setGlobalPlaceholders(values)); h('placeholders:globals:set', (values) => settings.setGlobalPlaceholders(values));
// capture // capture
h('capture:shoot', async ({ guideId, mode, delayMs }) => { h('capture:shoot', async ({ guideId, mode, delayMs }) => {
const result = await capture.shoot({ guideId, mode, delayMs }); const result = await capture.shoot({ guideId, mode, delayMs });
if (result.ok) reindex(guideId); if (result.ok) {
reindex(guideId);
const aiConf = settings.get('ai') || {};
if (aiConf.enabled && aiConf.autoDoc && result.step) {
const aiResult = await textIntel.generateStepPatch({
guideId,
stepId: result.step.stepId,
target: 'all',
}).catch(() => null);
if (aiResult?.ok) {
reindex(guideId);
result.step = aiResult.step;
}
}
}
return result; return result;
}); });
h('capture:region', async ({ guideId }) => { h('capture:region', async ({ guideId }) => {
const result = await capture.regionCapture(guideId); const result = await capture.regionCapture(guideId);
if (result.ok) reindex(guideId); if (result.ok) {
reindex(guideId);
const aiConf = settings.get('ai') || {};
if (aiConf.enabled && aiConf.autoDoc && result.step) {
const aiResult = await textIntel.generateStepPatch({
guideId,
stepId: result.step.stepId,
target: 'all',
}).catch(() => null);
if (aiResult?.ok) {
reindex(guideId);
result.step = aiResult.step;
}
}
}
return result; return result;
}); });
let capturePowerBlocker = -1;
const startCapturePower = () => {
if (!powerSaveBlocker.isStarted(capturePowerBlocker)) {
capturePowerBlocker = powerSaveBlocker.start('prevent-app-suspension');
}
};
const stopCapturePower = () => {
if (powerSaveBlocker.isStarted(capturePowerBlocker)) {
powerSaveBlocker.stop(capturePowerBlocker);
}
};
// Opt every live Electron process (browser, GPU, the screen-capture utility,
// any renderers) out of EcoQoS for the duration of a recording. The hidden
// capture-worker renderer is created later, during warmup, so it opts itself
// out separately (see stream-backend.js); this covers the rest.
const keepCaptureProcessesResponsive = () => {
try {
keepProcessesResponsive(app.getAppMetrics().map((m) => m.pid));
} catch { /* metrics unavailable — best effort */ }
};
h('capture:session', async ({ action, guideId, intervalSec }) => { h('capture:session', async ({ action, guideId, intervalSec }) => {
if (action === 'start') capture.startSession(guideId, { intervalSec: intervalSec ?? null }); if (action === 'start') {
else if (action === 'pause') capture.togglePause(true); capture.startSession(guideId, { intervalSec: intervalSec ?? null });
else if (action === 'resume') capture.togglePause(false); startCapturePower();
else if (action === 'finish') capture.finishSession(); keepCaptureProcessesResponsive();
else if (action === 'interval') capture.setInterval(intervalSec); } else if (action === 'pause') {
capture.togglePause(true);
stopCapturePower();
} else if (action === 'resume') {
capture.togglePause(false);
startCapturePower();
keepCaptureProcessesResponsive();
} else if (action === 'finish') {
capture.finishSession();
stopCapturePower();
} else if (action === 'interval') {
capture.setInterval(intervalSec);
}
const state = capture.state(); const state = capture.state();
sendToRenderer('capture:state', state); sendToRenderer('capture:state', state);
return state; return state;
@@ -569,13 +681,17 @@ function setupIpc() {
}); });
// export + preview // export + preview
h('export:formats', () => FORMATS.filter((f) => EXPORTERS[f])); h('export:formats', () => FORMATS.filter((f) => EXPORTERS[f]).map((format) => ({
id: format,
label: FORMAT_LABELS[format] || format,
})));
h('export:defaults', ({ format }) => { h('export:defaults', ({ format }) => {
// Exporter modules expose DEFAULT_TEMPLATE; the dialog renders editable // Exporter modules expose DEFAULT_TEMPLATE; the dialog renders editable
// options from it (booleans -> checkbox, numbers -> number, strings -> text). // options from it (booleans -> checkbox, numbers -> number, strings -> text).
const mod = { const mod = {
json: '../exporters/json', json: '../exporters/json',
markdown: '../exporters/markdown', markdown: '../exporters/markdown',
wikijs: '../exporters/wikijs',
'html-simple': '../exporters/html', 'html-simple': '../exporters/html',
'html-rich': '../exporters/html', 'html-rich': '../exporters/html',
confluence: '../exporters/confluence', confluence: '../exporters/confluence',
@@ -598,8 +714,14 @@ function setupIpc() {
dir = res.filePaths[0]; dir = res.filePaths[0];
} }
settings.set(`exports.lastOutputDirs.${format}`, dir); settings.set(`exports.lastOutputDirs.${format}`, dir);
const ast = buildRenderAst(store, guideId, { globals: settings.getGlobalPlaceholders() }); const result = await runExportInWorker({
const result = runExport(format, ast, dir, options || {}); dataDir: store.root,
guideId,
format,
options: options || {},
outDir: dir,
globals: settings.getGlobalPlaceholders(),
});
if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file); if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file);
return { ok: true, ...result }; return { ok: true, ...result };
}); });
@@ -666,11 +788,51 @@ if (!gotLock) {
settings = new Settings(store.settingsDir); settings = new Settings(store.settingsDir);
searchIndex = new SearchIndex(store.indexDir); searchIndex = new SearchIndex(store.indexDir);
templates = new TemplateManager(store.templatesDir); templates = new TemplateManager(store.templatesDir);
textIntel = new TextIntelService({
store,
settings,
getWindow: () => mainWindow,
dataDir,
screenApi: screen,
});
// Bringing up the desktop-capture stream spawns/upgrades Chromium's GPU
// and screen-capture utility processes — which can be born after a session
// already started, so the start-time EcoQoS opt-out misses them. Re-apply
// it the moment the backend reports it is streaming.
let lastClickFrameSource = null;
const captureNotify = (channel, payload) => {
sendToRenderer(channel, payload);
if (channel === 'capture:state' && payload && payload.clickFrameSource !== lastClickFrameSource) {
lastClickFrameSource = payload.clickFrameSource;
if (payload.clickFrameSource === 'stream') {
try { keepProcessesResponsive(app.getAppMetrics().map((m) => m.pid)); } catch { /* best effort */ }
}
}
// Auto-document session captures in the background when autoDoc is enabled.
// Single-shot captures (capture:shoot) are handled synchronously in the IPC handler.
if (channel === 'capture:added' && payload?.step && payload?.guideId) {
const aiConf = settings.get('ai') || {};
if (aiConf.enabled && aiConf.autoDoc) {
textIntel.generateStepPatch({
guideId: payload.guideId,
stepId: payload.step.stepId,
target: 'all',
}).then((result) => {
if (result.ok) {
reindex(payload.guideId);
sendToRenderer('step:updated', { guideId: payload.guideId, step: result.step });
}
}).catch(() => {});
}
}
};
capture = new CaptureService({ capture = new CaptureService({
store, store,
settings, settings,
getWindow: () => mainWindow, getWindow: () => mainWindow,
notify: sendToRenderer, notify: captureNotify,
textIntel,
}); });
applyTheme(); applyTheme();
@@ -690,6 +852,9 @@ if (!gotLock) {
capture.stopClickWatcher(); capture.stopClickWatcher();
capture.destroySessionTray(); capture.destroySessionTray();
} }
if (textIntel) {
textIntel.shutdown().catch(() => {});
}
// clean preview temp files on close // clean preview temp files on close
try { try {
for (const entry of fs.readdirSync(store.tempDir)) { for (const entry of fs.readdirSync(store.tempDir)) {
+7
View File
@@ -34,6 +34,7 @@ const api = {
add: invoke('step:add'), add: invoke('step:add'),
save: invoke('step:save'), save: invoke('step:save'),
delete: invoke('step:delete'), delete: invoke('step:delete'),
restore: invoke('step:restore'),
reorder: invoke('steps:reorder'), reorder: invoke('steps:reorder'),
imagePath: invoke('step:imagePath'), imagePath: invoke('step:imagePath'),
setWorkingImage: invoke('step:setWorkingImage'), setWorkingImage: invoke('step:setWorkingImage'),
@@ -51,6 +52,11 @@ const api = {
globalPlaceholders: invoke('placeholders:globals:get'), globalPlaceholders: invoke('placeholders:globals:get'),
setGlobalPlaceholders: invoke('placeholders:globals:set'), setGlobalPlaceholders: invoke('placeholders:globals:set'),
}, },
ai: {
test: invoke('ai:test'),
fillStep: invoke('ai:fillStep'),
rewriteText: invoke('ai:rewriteText'),
},
capture: { capture: {
shoot: invoke('capture:shoot'), shoot: invoke('capture:shoot'),
region: invoke('capture:region'), region: invoke('capture:region'),
@@ -58,6 +64,7 @@ const api = {
state: invoke('capture:state'), state: invoke('capture:state'),
onAdded: (fn) => ipcRenderer.on('capture:added', (e, payload) => fn(payload)), onAdded: (fn) => ipcRenderer.on('capture:added', (e, payload) => fn(payload)),
onState: (fn) => ipcRenderer.on('capture:state', (e, payload) => fn(payload)), onState: (fn) => ipcRenderer.on('capture:state', (e, payload) => fn(payload)),
onStepUpdated: (fn) => ipcRenderer.on('step:updated', (e, payload) => fn(payload)),
}, },
archive: { archive: {
export: invoke('archive:export'), export: invoke('archive:export'),
+23 -2
View File
@@ -80,6 +80,18 @@ class StepForgeApp {
api.capture.onAdded((payload) => this.onCaptureAdded(payload)); api.capture.onAdded((payload) => this.onCaptureAdded(payload));
api.capture.onState((payload) => this.updateCaptureState(payload)); api.capture.onState((payload) => this.updateCaptureState(payload));
api.capture.onStepUpdated((payload) => this.onStepUpdated(payload));
}
async onStepUpdated(payload) {
if (!payload || !payload.guideId || !payload.step) return;
if (this.state.view === 'editor' && this.editor.guideId === payload.guideId) {
const currentStep = this.editor.currentStep;
if (currentStep && currentStep.stepId === payload.step.stepId) {
await this.editor.reload(payload.step.stepId);
toast('Documentation generated.');
}
}
} }
async onCaptureAdded(payload) { async onCaptureAdded(payload) {
@@ -117,6 +129,7 @@ class StepForgeApp {
guideFolders: library.folders?.guideFolders || {}, guideFolders: library.folders?.guideFolders || {},
}; };
this.state.trash = trash; this.state.trash = trash;
this.editor.setSettings(settings);
} }
async refreshLibrary({ keepFilter = true } = {}) { async refreshLibrary({ keepFilter = true } = {}) {
@@ -279,7 +292,7 @@ class StepForgeApp {
} }
send({ action: s.paused ? 'resume' : 'pause' }); send({ action: s.paused ? 'resume' : 'pause' });
}, },
}, notStarted ? 'Start recording' : s.paused ? 'Resume' : 'Pause'); }, s.paused ? 'Start recording' : 'Stop recording');
this.captureStatus.append( this.captureStatus.append(
el('span', { title: `Capture session — ${trigger}` }, `Recording - ${trigger}`), el('span', { title: `Capture session — ${trigger}` }, `Recording - ${trigger}`),
@@ -317,8 +330,10 @@ class StepForgeApp {
const rect = e.target.getBoundingClientRect(); const rect = e.target.getBoundingClientRect();
contextMenu(rect.left, rect.bottom + 4, [ contextMenu(rect.left, rect.bottom + 4, [
{ label: 'Rename guide…', action: () => this.renameGuide() }, { label: 'Rename guide…', action: () => this.renameGuide() },
{ label: 'Guide information…', action: () => this.editor.openGuideInfo() },
{ label: 'Guide placeholders…', action: () => this.editor.openGuidePlaceholders() }, { label: 'Guide placeholders…', action: () => this.editor.openGuidePlaceholders() },
{ label: 'Backups & snapshots…', action: () => this.editor.openBackupsDialog() }, { label: 'Backups & snapshots…', action: () => this.editor.openBackupsDialog() },
{ label: 'Generate all text fields with AI (experimental)', action: () => this.editor.generateAllTextFieldsWithAi() },
{ label: guide && guide.linkedSource ? 'Linked guide…' : 'Linked guide (not linked)', action: () => this.editor.openLinkedGuide() }, { label: guide && guide.linkedSource ? 'Linked guide…' : 'Linked guide (not linked)', action: () => this.editor.openLinkedGuide() },
'sep', 'sep',
{ label: 'Keyboard shortcuts…', action: () => this.editor.openShortcutsHelp() }, { label: 'Keyboard shortcuts…', action: () => this.editor.openShortcutsHelp() },
@@ -756,7 +771,11 @@ class StepForgeApp {
if (title == null) return; if (title == null) return;
const guide = await api.library.create({ title: title.trim() || 'Untitled guide' }); const guide = await api.library.create({ title: title.trim() || 'Untitled guide' });
await this.refreshLibrary(); await this.refreshLibrary();
await this.openGuide(guide.guideId); // Arm a (paused) capture session like every other open path, so the
// "Start recording" bar appears and actually controls this new guide.
// Without this, a guide created from the library opened with no session,
// so Start recording had nothing to resume (or resumed a stale one).
await this.openGuideAndArmCapture(guide.guideId);
} }
async createFolder() { async createFolder() {
@@ -854,6 +873,7 @@ class StepForgeApp {
const settings = await api.settings.all(); const settings = await api.settings.all();
const placeholders = await api.settings.globalPlaceholders(); const placeholders = await api.settings.globalPlaceholders();
await dialogs.showSettingsDialog({ await dialogs.showSettingsDialog({
api,
settings, settings,
placeholders, placeholders,
onSave: async (next) => { onSave: async (next) => {
@@ -861,6 +881,7 @@ class StepForgeApp {
await api.settings.set({ keyPath: 'spellcheck', value: next.spellcheck }); await api.settings.set({ keyPath: 'spellcheck', value: next.spellcheck });
await api.settings.set({ keyPath: 'capture', value: next.capture }); await api.settings.set({ keyPath: 'capture', value: next.capture });
await api.settings.set({ keyPath: 'editor', value: next.editor }); await api.settings.set({ keyPath: 'editor', value: next.editor });
await api.settings.set({ keyPath: 'ai', value: next.ai });
await api.settings.set({ keyPath: 'exports', value: next.exports }); await api.settings.set({ keyPath: 'exports', value: next.exports });
await api.settings.set({ keyPath: 'backups', value: next.backups }); await api.settings.set({ keyPath: 'backups', value: next.backups });
await api.settings.setGlobalPlaceholders(next.placeholders || {}); await api.settings.setGlobalPlaceholders(next.placeholders || {});
+70 -17
View File
@@ -28,6 +28,7 @@ class AnnotationCanvas {
this.selectedId = null; this.selectedId = null;
this.drag = null; this.drag = null;
this.cropRect = null; this.cropRect = null;
this.focusedView = null;
canvasEl.addEventListener('pointerdown', (e) => this.onDown(e)); canvasEl.addEventListener('pointerdown', (e) => this.onDown(e));
canvasEl.addEventListener('pointermove', (e) => this.onMove(e)); canvasEl.addEventListener('pointermove', (e) => this.onMove(e));
@@ -55,6 +56,14 @@ class AnnotationCanvas {
this.render(); this.render();
} }
// The focused view crops and zooms the canvas itself to match what
// exports produce. Annotation data stays in full-image-normalized
// coordinates; only the on-screen viewport changes.
setFocusedView(focusedView) {
this.focusedView = focusedView || null;
this.render();
}
setTool(tool) { setTool(tool) {
this.tool = tool; this.tool = tool;
this.cropRect = null; this.cropRect = null;
@@ -98,30 +107,64 @@ class AnnotationCanvas {
} }
// ---- coordinate helpers ---- // ---- coordinate helpers ----
// The focused-view crop region, in coordinates normalized to the full
// image (0..1). Identity rect when focused view is off, matching
// core/raster.js applyFocusedView. panX/panY are 0..1 fractions of the
// available pan range (0 = left/bottom edge, 1 = right/top edge), so the
// slider's full range always pans the crop across the whole image
// regardless of zoom. Y is inverted so panY increases upward.
viewRect() {
const fv = this.focusedView;
if (!fv || !fv.enabled || !(fv.zoom > 1)) return { x: 0, y: 0, w: 1, h: 1 };
const w = 1 / fv.zoom, h = 1 / fv.zoom;
const panX = fv.panX ?? 0.5, panY = fv.panY ?? 0.5;
return { x: panX * (1 - w), y: (1 - panY) * (1 - h), w, h };
}
toNorm(e) { toNorm(e) {
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
const r = this.viewRect();
return { return {
x: (e.clientX - rect.left) / rect.width, x: r.x + ((e.clientX - rect.left) / rect.width) * r.w,
y: (e.clientY - rect.top) / rect.height, y: r.y + ((e.clientY - rect.top) / rect.height) * r.h,
}; };
} }
px(ann) { px(ann) {
const r = this.viewRect();
return { return {
x: ann.x * this.canvas.width, x: (ann.x - r.x) / r.w * this.canvas.width,
y: ann.y * this.canvas.height, y: (ann.y - r.y) / r.h * this.canvas.height,
w: ann.w * this.canvas.width, w: ann.w / r.w * this.canvas.width,
h: ann.h * this.canvas.height, h: ann.h / r.h * this.canvas.height,
}; };
} }
// Converts a canvas-pixel point to image-pixel coordinates in the
// full (uncropped) source image, for sampling `this.image` directly.
toImagePx(x, y) {
const r = this.viewRect();
return {
x: (x / this.canvas.width * r.w + r.x) * this.imgW,
y: (y / this.canvas.height * r.h + r.y) * this.imgH,
};
}
toImageLen(len, axis) {
const r = this.viewRect();
return axis === 'y' ? len / this.canvas.height * r.h * this.imgH : len / this.canvas.width * r.w * this.imgW;
}
// ---- rendering ---- // ---- rendering ----
render() { render() {
const { ctx, canvas } = this; const { ctx, canvas } = this;
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!this.image) return; if (!this.image) return;
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingEnabled = true;
ctx.drawImage(this.image, 0, 0, canvas.width, canvas.height); const r = this.viewRect();
ctx.drawImage(this.image,
r.x * this.imgW, r.y * this.imgH, r.w * this.imgW, r.h * this.imgH,
0, 0, canvas.width, canvas.height);
const ordered = [...this.annotations].sort((a, b) => (DRAW_ORDER[a.type] ?? 3) - (DRAW_ORDER[b.type] ?? 3)); const ordered = [...this.annotations].sort((a, b) => (DRAW_ORDER[a.type] ?? 3) - (DRAW_ORDER[b.type] ?? 3));
for (const ann of ordered) this.drawAnnotation(ann); for (const ann of ordered) this.drawAnnotation(ann);
@@ -131,12 +174,17 @@ class AnnotationCanvas {
if (this.cropRect) this.drawCropOverlay(); if (this.cropRect) this.drawCropOverlay();
} }
// Annotation strokes/fonts are sized relative to the full image, then
// magnified by the focused-view crop on export (core/raster.js) — divide
// by the view rect so the canvas preview matches that magnification.
strokePx(ann) { strokePx(ann) {
return Math.max(1, ((ann.style && ann.style.strokeWidth) || 3) * this.canvas.width / 1000); const r = this.viewRect();
return Math.max(1, ((ann.style && ann.style.strokeWidth) || 3) * this.canvas.width / 1000 / r.w);
} }
fontPx(ann) { fontPx(ann) {
return Math.max(9, ((ann.style && ann.style.fontSize) || 0.022) * this.canvas.height); const r = this.viewRect();
return Math.max(9, ((ann.style && ann.style.fontSize) || 0.022) * this.canvas.height / r.h);
} }
drawAnnotation(ann) { drawAnnotation(ann) {
@@ -202,10 +250,13 @@ class AnnotationCanvas {
ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2); ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
ctx.clip(); ctx.clip();
const sw = w / zoom, sh = h / zoom; const sw = w / zoom, sh = h / zoom;
const center = this.toImagePx(x + w / 2, y + h / 2);
const srcW = this.toImageLen(sw, 'x');
const srcH = this.toImageLen(sh, 'y');
ctx.drawImage( ctx.drawImage(
this.image, this.image,
(x + w / 2 - sw / 2) / this.scale, (y + h / 2 - sh / 2) / this.scale, center.x - srcW / 2, center.y - srcH / 2,
sw / this.scale, sh / this.scale, srcW, srcH,
x, y, w, h x, y, w, h
); );
ctx.restore(); ctx.restore();
@@ -310,10 +361,11 @@ class AnnotationCanvas {
drawCropOverlay() { drawCropOverlay() {
const { ctx, canvas } = this; const { ctx, canvas } = this;
const r = this.cropRect; const r = this.cropRect;
const x = Math.min(r.x0, r.x1) * canvas.width; const view = this.viewRect();
const y = Math.min(r.y0, r.y1) * canvas.height; const x = (Math.min(r.x0, r.x1) - view.x) / view.w * canvas.width;
const w = Math.abs(r.x1 - r.x0) * canvas.width; const y = (Math.min(r.y0, r.y1) - view.y) / view.h * canvas.height;
const h = Math.abs(r.y1 - r.y0) * canvas.height; const w = Math.abs(r.x1 - r.x0) / view.w * canvas.width;
const h = Math.abs(r.y1 - r.y0) / view.h * canvas.height;
ctx.save(); ctx.save();
ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.beginPath(); ctx.beginPath();
@@ -486,8 +538,9 @@ class AnnotationCanvas {
nudgeSelected(dx, dy) { nudgeSelected(dx, dy) {
const sel = this.selected(); const sel = this.selected();
if (!sel) return false; if (!sel) return false;
sel.x += dx / this.canvas.width; const r = this.viewRect();
sel.y += dy / this.canvas.height; sel.x += dx / this.canvas.width * r.w;
sel.y += dy / this.canvas.height * r.h;
this.changed(); this.changed();
return true; return true;
} }
+3 -1
View File
@@ -80,7 +80,9 @@
maxWidth: physWidth, maxWidth: physWidth,
minHeight: physHeight, minHeight: physHeight,
maxHeight: physHeight, maxHeight: physHeight,
maxFrameRate: 30, // No maxFrameRate: sampling cadence is controlled by the setInterval
// timer below, so the actual capture rate is always sampleMs-driven
// regardless of display refresh rate or power mode.
}, },
}, },
}); });
+310 -22
View File
@@ -24,6 +24,151 @@ function makeSelect(value, options) {
); );
} }
const HOTKEY_LABELS = {
CommandOrControl: 'Ctrl',
Control: 'Ctrl',
Command: 'Cmd',
Alt: 'Alt',
Shift: 'Shift',
Super: 'Super',
Up: '↑',
Down: '↓',
Left: '←',
Right: '→',
Space: 'Space',
Escape: 'Esc',
Return: 'Enter',
};
function hotkeyLabel(part) {
return HOTKEY_LABELS[part] || part;
}
/** Turn a keydown event into accelerator parts, or null if it's a bare modifier. */
function hotkeyFromEvent(e) {
const modifiers = [];
if (e.ctrlKey || e.metaKey) modifiers.push('CommandOrControl');
if (e.altKey) modifiers.push('Alt');
if (e.shiftKey) modifiers.push('Shift');
const { key } = e;
if (key === 'Control' || key === 'Meta' || key === 'Alt' || key === 'Shift') {
return { modifiers, key: null };
}
const SPECIAL_KEYS = {
' ': 'Space',
ArrowUp: 'Up',
ArrowDown: 'Down',
ArrowLeft: 'Left',
ArrowRight: 'Right',
Escape: 'Escape',
Enter: 'Return',
Tab: 'Tab',
Backspace: 'Backspace',
Delete: 'Delete',
};
let keyName = SPECIAL_KEYS[key];
if (!keyName) {
if (/^[a-zA-Z0-9]$/.test(key)) keyName = key.toUpperCase();
else if (/^F([1-9]|1[0-9]|2[0-4])$/.test(key)) keyName = key;
else return { modifiers, key: null };
}
return { modifiers, key: keyName };
}
/**
* A "press to record" shortcut field, styled like a row of keycaps. Exposes
* a `.value` getter/setter holding an Electron accelerator string (e.g.
* "CommandOrControl+Shift+1"), so it slots in like a text input.
*/
function makeHotkeyInput(value = '') {
let current = value || '';
const keys = el('div.hotkey-keys');
const clearBtn = el('button.hotkey-clear', {
type: 'button',
title: 'Clear shortcut',
onClick: (e) => {
e.stopPropagation();
current = '';
render();
},
}, '×');
const wrap = el('div.hotkey-input', { tabindex: '0', role: 'button', title: 'Click, then press a key combination' },
keys, clearBtn);
function renderKeys(parts) {
keys.replaceChildren();
parts.forEach((part, i) => {
if (i > 0) keys.append(el('span.hotkey-sep', {}, '+'));
keys.append(el('kbd', {}, hotkeyLabel(part)));
});
}
function render() {
if (wrap.classList.contains('recording')) {
keys.replaceChildren(el('span.hotkey-placeholder', {}, 'Press a key combination…'));
clearBtn.hidden = true;
return;
}
if (!current) {
keys.replaceChildren(el('span.hotkey-placeholder', {}, 'Click to set shortcut'));
clearBtn.hidden = true;
return;
}
renderKeys(current.split('+').filter(Boolean));
clearBtn.hidden = false;
}
// While recording, a window-level capturing listener intercepts keydown
// before the modal's own Escape handler can see it (capture order is
// window -> document -> ... -> target), so Escape cancels recording
// instead of closing the whole dialog.
let recordingKeyHandler = null;
wrap.addEventListener('focus', () => {
wrap.classList.add('recording');
render();
recordingKeyHandler = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.key === 'Escape') {
wrap.blur();
return;
}
const { modifiers, key } = hotkeyFromEvent(e);
if (key) {
current = [...modifiers, key].join('+');
wrap.blur();
} else if (modifiers.length) {
renderKeys([...modifiers, '…']);
} else {
render();
}
};
window.addEventListener('keydown', recordingKeyHandler, true);
});
wrap.addEventListener('blur', () => {
wrap.classList.remove('recording');
if (recordingKeyHandler) {
window.removeEventListener('keydown', recordingKeyHandler, true);
recordingKeyHandler = null;
}
render();
});
Object.defineProperty(wrap, 'value', {
get() { return current; },
set(v) { current = v || ''; render(); },
});
render();
return wrap;
}
async function promptText({ title, label = 'Value', value = '', placeholder = '', multiline = false } = {}) { async function promptText({ title, label = 'Value', value = '', placeholder = '', multiline = false } = {}) {
return new Promise((resolve) => { return new Promise((resolve) => {
const field = multiline const field = multiline
@@ -140,6 +285,7 @@ function showQuickActions({ query = '', commands = [], searchFn, onOpenItem, onC
} }
function showSettingsDialog({ function showSettingsDialog({
api,
settings, settings,
placeholders = {}, placeholders = {},
onSave, onSave,
@@ -160,14 +306,60 @@ function showSettingsDialog({
{ value: 'region', label: 'Region' }, { value: 'region', label: 'Region' },
]); ]);
const clickMarker = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.clickMarker) }); const clickMarker = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.clickMarker) });
const captureHotkey = makeInput(settings.capture?.hotkeyCapture || '', 'text'); const captureHotkey = makeHotkeyInput(settings.capture?.hotkeyCapture || '');
const pauseHotkey = makeInput(settings.capture?.hotkeyPauseResume || '', 'text'); const pauseHotkey = makeHotkeyInput(settings.capture?.hotkeyPauseResume || '');
const focusedDefault = el('input', { type: 'checkbox', checked: Boolean(settings.editor?.focusedViewDefaultForNewSteps) }); const focusedDefault = el('input', { type: 'checkbox', checked: Boolean(settings.editor?.focusedViewDefaultForNewSteps) });
const previewCount = makeInput(settings.exports?.previewStepCount ?? 3, 'number', { min: 1, step: 1 }); const previewCount = makeInput(settings.exports?.previewStepCount ?? 3, 'number', { min: 1, step: 1 });
const openFolder = el('input', { type: 'checkbox', checked: Boolean(settings.exports?.openFolderAfterExport) }); const openFolder = el('input', { type: 'checkbox', checked: Boolean(settings.exports?.openFolderAfterExport) });
const captureOutside = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.captureOutsideClicks) }); const captureOutside = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.captureOutsideClicks) });
const confirmSimple = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.confirmSimpleCapture) }); const confirmSimple = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.confirmSimpleCapture) });
const keepLast = makeInput(settings.backups?.keepLast ?? 10, 'number', { min: 0, step: 1 }); const keepLast = makeInput(settings.backups?.keepLast ?? 10, 'number', { min: 0, step: 1 });
const aiEnabled = el('input', { type: 'checkbox', checked: Boolean(settings.ai?.enabled) });
const aiAutoDoc = el('input', { type: 'checkbox', checked: Boolean(settings.ai?.autoDoc) });
const ollamaHost = makeInput(settings.ai?.ollama?.host || 'http://127.0.0.1:11434');
const ollamaModel = makeInput(settings.ai?.ollama?.model || 'llama3.2:1b');
const aiStatus = el('div', { className: 'muted ai-status' }, 'AI stays local through Ollama. Vision-capable models can also inspect the screenshot attached to each step.');
const testAiBtn = el('button', { type: 'button' }, 'Test connection');
const persistOllamaModel = debounce(() => {
const model = ollamaModel.value.trim();
// Keep the last model choice even if the dialog is dismissed without a full save.
void api.settings.set({ keyPath: 'ai.ollama.model', value: model }).catch(() => {});
}, 250);
const updateAiStatus = (message, { error = false } = {}) => {
aiStatus.textContent = message;
aiStatus.classList.toggle('error', Boolean(error));
};
const testAiConnection = async () => {
setButtonLoading(testAiBtn, true, 'Testing…');
updateAiStatus('Checking Ollama at the configured host…');
try {
const result = await api.ai.test({
ollama: {
host: ollamaHost.value.trim(),
model: ollamaModel.value.trim(),
},
});
if (!result.ok) {
updateAiStatus(result.reason || 'Could not connect to Ollama.', { error: true });
return;
}
if (result.installed) {
updateAiStatus(result.vision
? `Connected to ${result.host} with ${result.model}. It can inspect screenshots.`
: `Connected to ${result.host} with ${result.model}. This model is text-only, so StepForge will use OCR and metadata only.`);
} else {
updateAiStatus(`Connected to ${result.host}. Model ${result.model} is not installed yet.`, { error: true });
}
} catch (err) {
updateAiStatus(err.message || 'Could not connect to Ollama.', { error: true });
} finally {
setButtonLoading(testAiBtn, false);
}
};
ollamaModel.addEventListener('input', () => persistOllamaModel());
ollamaModel.addEventListener('blur', () => persistOllamaModel.flush());
const placeholderRows = el('div', { className: 'placeholder-rows' }); const placeholderRows = el('div', { className: 'placeholder-rows' });
const rows = []; const rows = [];
@@ -224,6 +416,20 @@ function showSettingsDialog({
el('legend', {}, 'Backups'), el('legend', {}, 'Backups'),
labeledRow('Keep last snapshots', keepLast), labeledRow('Keep last snapshots', keepLast),
), ),
el('fieldset', {},
el('legend', {}, 'AI'),
labeledRow('Enable AI (experimental)', aiEnabled),
labeledRow('Auto-document captures', aiAutoDoc),
labeledRow('Ollama host', ollamaHost),
labeledRow('Ollama model', ollamaModel),
el('div.row', { style: { justifyContent: 'space-between' } },
aiStatus,
testAiBtn,
),
el('div.muted', {},
'When auto-document is on, each capture is automatically documented by AI. Turn it off to use AI manually only.',
),
),
el('fieldset', {}, el('fieldset', {},
el('legend', {}, 'Global placeholders'), el('legend', {}, 'Global placeholders'),
placeholderRows, placeholderRows,
@@ -267,6 +473,16 @@ function showSettingsDialog({
...settings.backups, ...settings.backups,
keepLast: Number(keepLast.value || 0), keepLast: Number(keepLast.value || 0),
}, },
ai: {
...settings.ai,
enabled: aiEnabled.checked,
autoDoc: aiAutoDoc.checked,
ollama: {
...(settings.ai?.ollama || {}),
host: ollamaHost.value.trim(),
model: ollamaModel.value.trim(),
},
},
placeholders: rows.reduce((acc, row) => { placeholders: rows.reduce((acc, row) => {
const inputs = row.querySelectorAll('input'); const inputs = row.querySelectorAll('input');
const key = inputs[0].value.trim(); const key = inputs[0].value.trim();
@@ -285,6 +501,14 @@ function showSettingsDialog({
}); });
form.addEventListener('submit', (e) => e.preventDefault()); form.addEventListener('submit', (e) => e.preventDefault());
testAiBtn.addEventListener('click', testAiConnection);
aiEnabled.addEventListener('change', () => {
updateAiStatus(
aiEnabled.checked
? 'AI generation will be available once Ollama is reachable.'
: 'AI generation is disabled. The settings are still saved for later.',
);
});
}); });
} }
@@ -413,28 +637,43 @@ function showExportDialog({
outDir: outDirInput.value.trim() || null, outDir: outDirInput.value.trim() || null,
}); });
const cancelBtn = el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel');
const previewBtn = el('button', {
onClick: async () => {
if (typeof onPreview !== 'function') return;
setButtonLoading(previewBtn, true, 'Preview…');
try {
await onPreview(payload()); // keep dialog open so settings can be tweaked
} finally {
setButtonLoading(previewBtn, false);
}
},
}, 'Preview');
const exportBtn = el('button.primary', {
onClick: async () => {
if (typeof onExport !== 'function') return;
cancelBtn.disabled = true;
previewBtn.disabled = true;
setButtonLoading(exportBtn, true, 'Exporting…');
try {
const ok = await onExport(payload());
if (ok !== false) {
close();
resolve(true);
return;
}
} finally {
cancelBtn.disabled = false;
previewBtn.disabled = false;
setButtonLoading(exportBtn, false);
}
},
}, 'Export');
const { close } = openModal({ const { close } = openModal({
title: 'Export', title: 'Export',
body, body,
footer: [ footer: [cancelBtn, previewBtn, exportBtn],
el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'),
el('button', {
onClick: async () => {
if (typeof onPreview !== 'function') return;
await onPreview(payload()); // keep dialog open so settings can be tweaked
},
}, 'Preview'),
el('button.primary', {
onClick: async () => {
if (typeof onExport !== 'function') return;
const ok = await onExport(payload());
if (ok !== false) {
close();
resolve(true);
}
},
}, 'Export'),
],
wide: true, wide: true,
onClose: () => resolve(false), onClose: () => resolve(false),
}); });
@@ -639,6 +878,53 @@ function showPlaceholdersDialog({ title = 'Placeholders', hint = '', values = {}
}); });
} }
/**
* Optional document metadata (author, co-authors, organization, description)
* shown at the top of the guide, below the title, and surfaced on the PDF
* cover page and the top of other export formats.
*/
function showGuideInfoDialog({ values = {}, onSave } = {}) {
return new Promise((resolve) => {
const authorInput = makeInput(values.author || '', 'text', { placeholder: 'e.g. Jane Doe' });
const coAuthorsInput = makeInput(values.coAuthors || '', 'text', { placeholder: 'e.g. Alex Lee, Sam Patel' });
const organizationInput = makeInput(values.organization || '', 'text', { placeholder: 'e.g. Acme Corp' });
const descriptionInput = el('textarea', {
rows: 4,
placeholder: 'A short summary of this guide.',
}, values.description || '');
const { close } = openModal({
title: 'Guide information',
body: el('div', {},
el('div.muted', { style: { marginBottom: '10px' } },
'All fields are optional. They appear below the title on the guide and on the PDF cover page.'),
labeledRow('Author', authorInput),
labeledRow('Co-authors', coAuthorsInput),
labeledRow('Organization', organizationInput),
labeledRow('Description', descriptionInput, { stacked: true }),
el('div.muted', { style: { marginTop: '-4px' } },
'Shown on the first page of the PDF and at the top of other export formats.'),
),
footer: [
el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'),
el('button.primary', {
onClick: async () => {
await onSave?.({
author: authorInput.value.trim(),
coAuthors: coAuthorsInput.value.trim(),
organization: organizationInput.value.trim(),
description: descriptionInput.value.trim(),
});
close();
resolve(true);
},
}, 'Save'),
],
onClose: () => resolve(false),
});
});
}
const SHORTCUTS = [ const SHORTCUTS = [
['Capture & steps', [ ['Capture & steps', [
['Ctrl+S', 'Save (writes linked archive when guide is linked)'], ['Ctrl+S', 'Save (writes linked archive when guide is linked)'],
@@ -646,10 +932,11 @@ const SHORTCUTS = [
['PageUp / PageDown', 'Previous / next step'], ['PageUp / PageDown', 'Previous / next step'],
['Alt+↑ / Alt+↓', 'Move step up / down'], ['Alt+↑ / Alt+↓', 'Move step up / down'],
['Ctrl+Delete', 'Delete current step'], ['Ctrl+Delete', 'Delete current step'],
['Ctrl+Z / Ctrl+Shift+Z', 'Undo / redo (including step deletion)'],
['Ctrl+V', 'Paste annotation, or clipboard image as new step'], ['Ctrl+V', 'Paste annotation, or clipboard image as new step'],
]], ]],
['Canvas tools', [ ['Canvas tools', [
['S R O L A T', 'Select · Rect · Oval · Line · Arrow · Text'], ['S R O L A T', 'Select · Rectangle · Oval · Line · Arrow · Text'],
['G N B H M U C', 'Tooltip · Number · Blur · Highlight · Magnify · Cursor · Crop'], ['G N B H M U C', 'Tooltip · Number · Blur · Highlight · Magnify · Cursor · Crop'],
['Ctrl+C', 'Copy selected annotation'], ['Ctrl+C', 'Copy selected annotation'],
['Delete', 'Delete selected annotation'], ['Delete', 'Delete selected annotation'],
@@ -726,6 +1013,7 @@ window.StepForgeDialogs = {
showInfoDialog, showInfoDialog,
showBackupsDialog, showBackupsDialog,
showPlaceholdersDialog, showPlaceholdersDialog,
showGuideInfoDialog,
showShortcutsDialog, showShortcutsDialog,
showTemplateManager, showTemplateManager,
showRecordingReminder, showRecordingReminder,
+532 -90
View File
@@ -8,6 +8,37 @@ const dialogs = window.StepForgeDialogs || {};
const clone = (value) => JSON.parse(JSON.stringify(value)); const clone = (value) => JSON.parse(JSON.stringify(value));
const BLOCK_KIND_ORDER = { text: 0, code: 1, table: 2 }; const BLOCK_KIND_ORDER = { text: 0, code: 1, table: 2 };
// Which style fields are meaningful for each annotation type, so the
// annotation editor only shows controls that actually affect that type.
const ANNOTATION_FIELDS = {
rect: ['stroke', 'fill', 'strokeWidth'],
oval: ['stroke', 'fill', 'strokeWidth'],
line: ['stroke', 'strokeWidth'],
arrow: ['stroke', 'strokeWidth'],
text: ['text', 'stroke', 'fontSize'],
tooltip: ['text', 'fill', 'stroke', 'strokeWidth', 'fontSize', 'textColor', 'tail'],
number: ['value', 'stroke', 'textColor'],
blur: ['radius'],
highlight: [],
magnify: ['zoom', 'stroke', 'strokeWidth'],
cursor: [],
};
// Display names for annotation types in the "Type" dropdown.
const ANNOTATION_TYPE_LABELS = {
rect: 'Rectangle',
oval: 'Oval',
line: 'Line',
arrow: 'Arrow',
text: 'Text',
tooltip: 'Tooltip',
number: 'Number',
blur: 'Blur',
highlight: 'Highlight',
magnify: 'Magnify',
cursor: 'Cursor',
};
function blockText(block) { function blockText(block) {
for (const key of ['code', 'text', 'body', 'value', 'content']) { for (const key of ['code', 'text', 'body', 'value', 'content']) {
const value = block && block[key]; const value = block && block[key];
@@ -56,6 +87,16 @@ function stepNumberMap(steps) {
return numbers; return numbers;
} }
function stepDepth(step, stepMap) {
let depth = 0;
let parent = step.parentStepId;
while (parent && stepMap.has(parent)) {
depth += 1;
parent = stepMap.get(parent).parentStepId;
}
return depth;
}
function isEditableTarget(target) { function isEditableTarget(target) {
return target && ( return target && (
target.tagName === 'INPUT' || target.tagName === 'INPUT' ||
@@ -94,6 +135,7 @@ class GuideEditor {
this.descriptionDirty = false; this.descriptionDirty = false;
this.titleDirty = false; this.titleDirty = false;
this.active = true; this.active = true;
this.settings = {};
this.saveStepDebounced = debounce(() => this.flushStep(), 180); this.saveStepDebounced = debounce(() => this.flushStep(), 180);
this.saveGuideDebounced = debounce(() => this.flushGuide(), 180); this.saveGuideDebounced = debounce(() => this.flushGuide(), 180);
@@ -111,6 +153,149 @@ class GuideEditor {
this.active = Boolean(active); this.active = Boolean(active);
} }
setSettings(settings) {
this.settings = settings || {};
this.updateAiButtonState();
}
isAiEnabled() {
return Boolean(this.settings?.ai?.enabled);
}
updateAiButtonState() {
const enabled = this.isAiEnabled();
const buttons = [
this.dom?.titleAiBtn,
this.dom?.descAiBtn,
...(this.dom?.blocksList ? [...this.dom.blocksList.querySelectorAll('button[data-ai-action]')] : []),
].filter(Boolean);
for (const button of buttons) {
button.disabled = !enabled;
button.title = enabled
? button.dataset.aiTitle || 'Generate with AI'
: 'Enable AI in Settings first.';
}
}
async runAiGeneration(target, { blockId = null, button = null } = {}) {
if (!this.currentStep) {
this.onToast('Select a step first.', { error: true });
return null;
}
if (!this.isAiEnabled()) {
this.onToast('Enable AI in Settings first.', { error: true });
return null;
}
if (this.pendingSave) await this.flushStep();
if (button) setButtonLoading(button, true, 'Generating…');
try {
const result = await api.ai.fillStep({
guideId: this.guideId,
stepId: this.currentStep.stepId,
target,
blockId,
});
if (!result || !result.ok) {
this.onToast(result?.reason || 'AI generation failed.', { error: true });
return null;
}
await this.reload(result.step.stepId);
this.onToast('AI text filled.');
return result.step;
} catch (err) {
this.onToast(err.message || 'AI generation failed.', { error: true });
return null;
} finally {
if (button) setButtonLoading(button, false);
}
}
async generateTitleWithAi(button = null) {
return this.runAiGeneration('title', { button });
}
async generateDescriptionWithAi(button = null) {
return this.runAiGeneration('description', { button });
}
async generateAllTextFieldsWithAi(button = null) {
if (!this.steps.length) {
this.onToast('No steps to generate.', { error: true });
return;
}
if (!this.isAiEnabled()) {
this.onToast('Enable AI in Settings first.', { error: true });
return;
}
if (this.pendingSave) await this.flushStep();
// Only fill fields that are actually empty — never overwrite user-written content.
const PLACEHOLDER_TITLES = new Set([
'', 'screen capture', 'window capture', 'region capture', 'capture', 'untitled step',
]);
const isEmptyDesc = (html) => !(html || '').replace(/<[^>]*>/g, '').trim();
const queue = this.steps
.map((step) => {
const titleEmpty = !step.title || PLACEHOLDER_TITLES.has(step.title.toLowerCase());
const descEmpty = isEmptyDesc(step.descriptionHtml);
if (!titleEmpty && !descEmpty) return null;
const target = titleEmpty && descEmpty ? 'all' : titleEmpty ? 'title' : 'description';
return { stepId: step.stepId, target };
})
.filter(Boolean);
if (!queue.length) {
this.onToast('All steps already have titles and descriptions.');
return;
}
if (button) setButtonLoading(button, true, 'Generating…');
let done = 0;
let failed = 0;
const total = queue.length;
try {
for (const { stepId, target } of queue) {
this.onToast(`AI: filling step ${done + failed + 1} of ${total}`);
try {
const result = await api.ai.fillStep({ guideId: this.guideId, stepId, target });
if (result?.ok) done++;
else failed++;
} catch {
failed++;
}
}
// Reload re-fetches all steps from the store so the editor and list both reflect the new text.
await this.reload(this.selectedStepId);
const msg = failed
? `AI filled ${done} step${done === 1 ? '' : 's'} (${failed} failed).`
: `AI filled ${done} step${done === 1 ? '' : 's'}.`;
this.onToast(msg, failed ? { error: true } : undefined);
} finally {
if (button) setButtonLoading(button, false);
}
}
async generateBlockWithAi(kind, block, button = null) {
if (!block) return null;
return this.runAiGeneration('block', { blockId: block.id, button });
}
updateAiButtonHints() {
const PLACEHOLDER_TITLES = new Set([
'', 'screen capture', 'window capture', 'region capture', 'capture',
]);
if (this.dom.titleAiBtn) {
const val = (this.dom.titleInput?.value || '').trim();
const hasDraft = Boolean(val) && !PLACEHOLDER_TITLES.has(val.toLowerCase());
this.dom.titleAiBtn.title = hasDraft ? 'Rewrite step title with AI' : 'Generate step title with AI';
}
if (this.dom.descAiBtn) {
const hasDesc = Boolean((this.dom.descEditor?.innerText || '').trim());
this.dom.descAiBtn.title = hasDesc ? 'Rewrite description with AI' : 'Generate description with AI';
}
}
get currentStep() { get currentStep() {
return this.stepMap.get(this.selectedStepId) || null; return this.stepMap.get(this.selectedStepId) || null;
} }
@@ -173,7 +358,7 @@ class GuideEditor {
this.root.innerHTML = ''; this.root.innerHTML = '';
const toolButtons = [ const toolButtons = [
['select', 'Select'], ['select', 'Select'],
['rect', 'Rect'], ['rect', 'Rectangle'],
['oval', 'Oval'], ['oval', 'Oval'],
['line', 'Line'], ['line', 'Line'],
['arrow', 'Arrow'], ['arrow', 'Arrow'],
@@ -181,7 +366,7 @@ class GuideEditor {
['tooltip', 'Tip'], ['tooltip', 'Tip'],
['number', '#'], ['number', '#'],
['blur', 'Blur'], ['blur', 'Blur'],
['highlight', 'Hi'], ['highlight', 'Highlight'],
['magnify', 'Mag'], ['magnify', 'Mag'],
['cursor', 'Cursor'], ['cursor', 'Cursor'],
['crop', 'Crop'], ['crop', 'Crop'],
@@ -230,7 +415,14 @@ class GuideEditor {
el('aside.pane-props', {}, el('aside.pane-props', {},
el('section', {}, el('section', {},
el('h3', {}, 'Step'), el('h3', {}, 'Step'),
this.dom.titleInput = el('input', { type: 'text', placeholder: 'Step title' }), el('div.row', {},
this.dom.titleInput = el('input', { type: 'text', placeholder: 'Step title', style: { flex: 1 } }),
this.dom.titleAiBtn = el('button.ai', {
type: 'button',
title: 'Generate the step title with AI',
dataset: { aiAction: 'title', aiTitle: 'Generate the step title with AI' },
}, 'AI'),
),
this.dom.statusSelect = makeSelect('todo', [ this.dom.statusSelect = makeSelect('todo', [
{ value: 'todo', label: 'Todo' }, { value: 'todo', label: 'Todo' },
{ value: 'in-progress', label: 'In progress' }, { value: 'in-progress', label: 'In progress' },
@@ -255,7 +447,14 @@ class GuideEditor {
), ),
), ),
el('section', {}, el('section', {},
el('h3', {}, 'Description'), el('div.row', { style: { justifyContent: 'space-between', alignItems: 'center' } },
el('h3', { style: { margin: 0 } }, 'Description'),
this.dom.descAiBtn = el('button.ai', {
type: 'button',
title: 'Generate the description with AI',
dataset: { aiAction: 'description', aiTitle: 'Generate the description with AI' },
}, 'AI'),
),
this.dom.richToolbar = el('div.rich-toolbar', {}, this.dom.richToolbar = el('div.rich-toolbar', {},
this.toolbarBtn('bold', 'Bold'), this.toolbarBtn('bold', 'Bold'),
this.toolbarBtn('italic', 'Italic'), this.toolbarBtn('italic', 'Italic'),
@@ -284,8 +483,7 @@ class GuideEditor {
el('section', {}, el('section', {},
el('h3', {}, 'Guide'), el('h3', {}, 'Guide'),
this.dom.guideSummary = el('div.muted', {}), this.dom.guideSummary = el('div.muted', {}),
this.dom.linkedBtn = el('button', { type: 'button' }, 'Linked guide'), this.dom.saveNowBtn = el('button.primary', { type: 'button', title: 'Save changes. For guides linked to a shared archive, also writes the archive file.' }, 'Save now'),
this.dom.saveNowBtn = el('button.primary', { type: 'button' }, 'Save now'),
this.dom.snapshotBtn = el('button', { type: 'button' }, 'Snapshot'), this.dom.snapshotBtn = el('button', { type: 'button' }, 'Snapshot'),
), ),
), ),
@@ -314,10 +512,32 @@ class GuideEditor {
} }
toolbarBtn(action, label, block = null) { toolbarBtn(action, label, block = null) {
return el('button', { const btn = el('button', {
type: 'button', type: 'button',
onClick: () => this.formatDescription(action, block), onClick: () => this.formatDescription(action, block),
}, label); }, label);
btn.dataset.action = action;
if (block) btn.dataset.block = block;
return btn;
}
/** Reflect the current selection's formatting state on the toolbar buttons. */
updateToolbarState() {
if (!this.dom.richToolbar) return;
for (const btn of this.dom.richToolbar.children) {
const { action, block } = btn.dataset;
let active = false;
try {
if (action === 'formatBlock') {
active = document.queryCommandValue('formatBlock').toLowerCase() === (block || 'blockquote');
} else if (action === 'bold' || action === 'italic' || action === 'insertUnorderedList' || action === 'insertOrderedList') {
active = document.queryCommandState(action);
}
} catch {
active = false;
}
btn.classList.toggle('active', active);
}
} }
bindShellEvents() { bindShellEvents() {
@@ -330,7 +550,6 @@ class GuideEditor {
this.dom.deleteBtn.addEventListener('click', () => this.deleteSelectedStep()); this.dom.deleteBtn.addEventListener('click', () => this.deleteSelectedStep());
this.dom.saveNowBtn.addEventListener('click', () => this.saveAll()); this.dom.saveNowBtn.addEventListener('click', () => this.saveAll());
this.dom.snapshotBtn.addEventListener('click', () => this.createSnapshot()); this.dom.snapshotBtn.addEventListener('click', () => this.createSnapshot());
this.dom.linkedBtn.addEventListener('click', () => this.openLinkedGuide());
this.dom.zoomFitBtn.addEventListener('click', () => this.setZoom('fit')); this.dom.zoomFitBtn.addEventListener('click', () => this.setZoom('fit'));
this.dom.zoom100Btn.addEventListener('click', () => this.setZoom(1)); this.dom.zoom100Btn.addEventListener('click', () => this.setZoom(1));
this.dom.zoom125Btn.addEventListener('click', () => this.setZoom(1.25)); this.dom.zoom125Btn.addEventListener('click', () => this.setZoom(1.25));
@@ -354,7 +573,9 @@ class GuideEditor {
this.saveStepDebounced(); this.saveStepDebounced();
this.renderStepList(); this.renderStepList();
this.emitMeta(); this.emitMeta();
this.updateAiButtonHints();
}); });
this.dom.titleAiBtn.addEventListener('click', () => this.generateTitleWithAi(this.dom.titleAiBtn));
this.dom.statusSelect.addEventListener('change', () => { this.dom.statusSelect.addEventListener('change', () => {
if (!this.currentStep) return; if (!this.currentStep) return;
@@ -397,6 +618,7 @@ class GuideEditor {
step.focusedView[field] = Number(node.value); step.focusedView[field] = Number(node.value);
this.pendingSave = true; this.pendingSave = true;
this.saveStepDebounced(); this.saveStepDebounced();
this.canvas.setFocusedView(step.focusedView);
}); });
bindFocusedSlider(this.dom.fvZoom, 'zoom'); bindFocusedSlider(this.dom.fvZoom, 'zoom');
bindFocusedSlider(this.dom.fvPanX, 'panX'); bindFocusedSlider(this.dom.fvPanX, 'panX');
@@ -407,7 +629,14 @@ class GuideEditor {
this.dom.addTableBlockBtn.addEventListener('click', () => this.addBlock('table')); this.dom.addTableBlockBtn.addEventListener('click', () => this.addBlock('table'));
this.dom.descEditor.addEventListener('focus', () => { this.dom.descEditor.addEventListener('focus', () => {
// Make Enter start a new paragraph (<p>) rather than a plain <div>,
// so line breaks survive sanitization and show up in exports.
document.execCommand('defaultParagraphSeparator', false, 'p');
if (this.currentStep) this.pushCanvasHistory('description'); if (this.currentStep) this.pushCanvasHistory('description');
this.updateToolbarState();
});
this.dom.descEditor.addEventListener('blur', () => {
for (const btn of this.dom.richToolbar.children) btn.classList.remove('active');
}); });
this.dom.descEditor.addEventListener('input', () => { this.dom.descEditor.addEventListener('input', () => {
if (!this.currentStep) return; if (!this.currentStep) return;
@@ -415,7 +644,15 @@ class GuideEditor {
this.pendingSave = true; this.pendingSave = true;
this.saveStepDebounced(); this.saveStepDebounced();
this.emitMeta(); this.emitMeta();
this.updateToolbarState();
this.updateAiButtonHints();
}); });
this.dom.descEditor.addEventListener('keyup', () => this.updateToolbarState());
this.dom.descEditor.addEventListener('mouseup', () => this.updateToolbarState());
document.addEventListener('selectionchange', () => {
if (document.activeElement === this.dom.descEditor) this.updateToolbarState();
});
this.dom.descAiBtn.addEventListener('click', () => this.generateDescriptionWithAi(this.dom.descAiBtn));
this.dom.descEditor.addEventListener('paste', (e) => { this.dom.descEditor.addEventListener('paste', (e) => {
// Keep pasted text simple; backend sanitization will handle the rest. // Keep pasted text simple; backend sanitization will handle the rest.
@@ -429,6 +666,8 @@ class GuideEditor {
if (!item) return; if (!item) return;
this.canvas.select(item.dataset.annId); this.canvas.select(item.dataset.annId);
}); });
this.updateAiButtonState();
} }
renderAll() { renderAll() {
@@ -438,7 +677,7 @@ class GuideEditor {
this.renderCanvas(); this.renderCanvas();
this.renderAnnotationPanel(); this.renderAnnotationPanel();
this.renderBlocksPanel(); this.renderBlocksPanel();
this.renderGuidePanel(); this.updateAiButtonState();
this.emitMeta(); this.emitMeta();
} }
@@ -451,6 +690,7 @@ class GuideEditor {
this.dom.fvPanX.value = fv.panX ?? 0.5; this.dom.fvPanX.value = fv.panX ?? 0.5;
this.dom.fvPanY.value = fv.panY ?? 0.5; this.dom.fvPanY.value = fv.panY ?? 0.5;
} }
this.canvas.setFocusedView(fv);
} }
// ---- text / code / table blocks ---------------------------------------- // ---- text / code / table blocks ----------------------------------------
@@ -595,6 +835,15 @@ class GuideEditor {
el('span.spacer'), el('span.spacer'),
el('button.icon', { type: 'button', title: 'Move block up', disabled: !canMoveUp, onClick: moveUp, dataset: { blockMove: 'up' } }, '↑'), el('button.icon', { type: 'button', title: 'Move block up', disabled: !canMoveUp, onClick: moveUp, dataset: { blockMove: 'up' } }, '↑'),
el('button.icon', { type: 'button', title: 'Move block down', disabled: !canMoveDown, onClick: moveDown, dataset: { blockMove: 'down' } }, '↓'), el('button.icon', { type: 'button', title: 'Move block down', disabled: !canMoveDown, onClick: moveDown, dataset: { blockMove: 'down' } }, '↓'),
(() => {
const aiBtn = el('button.ai', {
type: 'button',
title: 'Generate this block with AI',
dataset: { aiAction: 'block', aiTitle: 'Generate this block with AI' },
onClick: () => this.generateBlockWithAi(kind, block, aiBtn),
}, 'AI');
return aiBtn;
})(),
removeBtn(() => { removeBtn(() => {
if (kind === 'text') step.textBlocks = (step.textBlocks || []).filter((b) => b !== block); if (kind === 'text') step.textBlocks = (step.textBlocks || []).filter((b) => b !== block);
else if (kind === 'code') step.codeBlocks = (step.codeBlocks || []).filter((b) => b !== block); else if (kind === 'code') step.codeBlocks = (step.codeBlocks || []).filter((b) => b !== block);
@@ -624,6 +873,7 @@ class GuideEditor {
}, header); }, header);
if (kind === 'text') { if (kind === 'text') {
card.dataset.level = block.level || 'info';
const position = makeSelect(block.position, [ const position = makeSelect(block.position, [
{ value: 'before-title', label: 'Before title' }, { value: 'before-title', label: 'Before title' },
{ value: 'after-title', label: 'After title' }, { value: 'after-title', label: 'After title' },
@@ -646,7 +896,7 @@ class GuideEditor {
body.dataset.blockField = 'body'; body.dataset.blockField = 'body';
body.value = (block.descriptionHtml || '').replace(/<[^>]+>/g, ''); body.value = (block.descriptionHtml || '').replace(/<[^>]+>/g, '');
position.addEventListener('change', () => { block.position = position.value; save(); }); position.addEventListener('change', () => { block.position = position.value; save(); });
level.addEventListener('change', () => { block.level = level.value; save(); }); level.addEventListener('change', () => { block.level = level.value; card.dataset.level = level.value; save(); });
title.addEventListener('input', () => { block.title = title.value; save(); }); title.addEventListener('input', () => { block.title = title.value; save(); });
body.addEventListener('input', () => { block.descriptionHtml = `<p>${escapeHtml(body.value)}</p>`; save(); }); body.addEventListener('input', () => { block.descriptionHtml = `<p>${escapeHtml(body.value)}</p>`; save(); });
card.append( card.append(
@@ -685,6 +935,7 @@ class GuideEditor {
if (!blocks.length) { if (!blocks.length) {
this.dom.blocksList.append(el('div.muted', {}, 'Informational text, code, and table blocks can be reordered with drag handles or arrows.')); this.dom.blocksList.append(el('div.muted', {}, 'Informational text, code, and table blocks can be reordered with drag handles or arrows.'));
} }
this.updateAiButtonState();
} }
renderStepList() { renderStepList() {
@@ -695,15 +946,10 @@ class GuideEditor {
this.dom.selectStepsBtn.className = this.stepSelectMode ? 'primary' : ''; this.dom.selectStepsBtn.className = this.stepSelectMode ? 'primary' : '';
for (const step of this.steps) { for (const step of this.steps) {
const number = numbers.get(step.stepId) || ''; const number = numbers.get(step.stepId) || '';
let depth = 0; const depth = stepDepth(step, this.stepMap);
let parent = step.parentStepId;
while (parent && this.stepMap.has(parent)) {
depth += 1;
parent = this.stepMap.get(parent).parentStepId;
}
const selected = current && current.stepId === step.stepId; const selected = current && current.stepId === step.stepId;
const checked = this.selectedSteps.has(step.stepId); const checked = this.selectedSteps.has(step.stepId);
const item = el('div.step-item', { const itemProps = {
className: `step-item${selected ? ' selected' : ''}${depth ? ' sub' : ''}${step.skipped ? ' skipped' : ''}${step.hidden ? ' hiddenstep' : ''}`, className: `step-item${selected ? ' selected' : ''}${depth ? ' sub' : ''}${step.skipped ? ' skipped' : ''}${step.hidden ? ' hiddenstep' : ''}`,
dataset: { stepId: step.stepId }, dataset: { stepId: step.stepId },
onClick: () => { onClick: () => {
@@ -716,6 +962,18 @@ class GuideEditor {
this.selectStep(step.stepId); this.selectStep(step.stepId);
contextMenu(e.clientX, e.clientY, [ contextMenu(e.clientX, e.clientY, [
{ label: 'Add substep', action: () => this.addSubstep(step.stepId) }, { label: 'Add substep', action: () => this.addSubstep(step.stepId) },
{
label: 'Make substep of…',
submenu: () => {
const subtreeIds = new Set([step.stepId, ...this.getStepDescendantIds(step.stepId)]);
return this.steps
.filter((s) => !subtreeIds.has(s.stepId))
.map((s) => ({
label: `${numbers.get(s.stepId)} ${s.title || 'Untitled step'}`,
action: () => this.makeSubstepOf(step.stepId, s.stepId),
}));
},
},
{ label: 'Duplicate step', action: () => this.duplicateSelectedStep() }, { label: 'Duplicate step', action: () => this.duplicateSelectedStep() },
'sep', 'sep',
{ label: 'Move up', action: () => this.moveSelectedStep(-1) }, { label: 'Move up', action: () => this.moveSelectedStep(-1) },
@@ -724,23 +982,26 @@ class GuideEditor {
{ label: 'Delete step', danger: true, action: () => this.deleteSelectedStep() }, { label: 'Delete step', danger: true, action: () => this.deleteSelectedStep() },
]); ]);
}, },
}, };
this.stepSelectMode if (depth) itemProps.style = { marginLeft: `${depth * 18}px` };
? el('input', { const item = el('div.step-item', itemProps,
type: 'checkbox', this.stepSelectMode
checked, ? el('input', {
onClick: (e) => e.stopPropagation(), type: 'checkbox',
onChange: () => this.toggleStepSelection(step.stepId), checked,
}) onClick: (e) => e.stopPropagation(),
: null, onChange: () => this.toggleStepSelection(step.stepId),
el('span.status-dot', { className: `status-dot status-${step.status}` }), })
el('span.num', {}, number || '•'), : null,
el('span.t', {}, step.title || 'Untitled step'), el('span.status-dot', { className: `status-dot status-${step.status}` }),
el('span.flags', {}, [ el('span.num', {}, number || '•'),
step.parentStepId ? 'sub' : '', el('span.t', {}, step.title || 'Untitled step'),
step.hidden ? 'hidden' : '', el('span.flags', {}, [
step.skipped ? 'skipped' : '', step.parentStepId ? 'sub' : '',
].filter(Boolean).join(' · '))); step.hidden ? 'hidden' : '',
step.skipped ? 'skipped' : '',
].filter(Boolean).join(' · ')),
);
this.dom.stepsList.append(item); this.dom.stepsList.append(item);
} }
if (!this.steps.length) { if (!this.steps.length) {
@@ -795,13 +1056,21 @@ class GuideEditor {
if (!ids.length) return; if (!ids.length) return;
const ok = await confirmDialog(`Delete ${ids.length} step${ids.length === 1 ? '' : 's'}?`, { danger: true, okLabel: 'Delete' }); const ok = await confirmDialog(`Delete ${ids.length} step${ids.length === 1 ? '' : 's'}?`, { danger: true, okLabel: 'Delete' });
if (!ok) return; if (!ok) return;
if (this.pendingSave) await this.flushStep();
const order = this.steps.map((s) => s.stepId);
const entries = [];
for (const stepId of ids) {
const step = this.stepMap.get(stepId);
if (step) entries.push(await this.snapshotStepForDeletion(step));
}
for (const stepId of ids) { for (const stepId of ids) {
await api.step.delete({ guideId: this.guideId, stepId }); await api.step.delete({ guideId: this.guideId, stepId });
} }
if (entries.length) this.pushCanvasHistory({ type: 'delete-step', steps: entries, order });
this.stepSelectMode = false; this.stepSelectMode = false;
this.selectedSteps = new Set(); this.selectedSteps = new Set();
await this.reload(null); await this.reload(null);
this.onToast(`${ids.length} step${ids.length === 1 ? '' : 's'} deleted.`); this.onToast(`${ids.length} step${ids.length === 1 ? '' : 's'} deleted. Press Ctrl+Z to undo.`);
} }
syncStepFields() { syncStepFields() {
@@ -821,6 +1090,7 @@ class GuideEditor {
if (document.activeElement !== this.dom.titleInput) this.dom.titleInput.value = step.title || ''; if (document.activeElement !== this.dom.titleInput) this.dom.titleInput.value = step.title || '';
if (document.activeElement !== this.dom.descEditor) this.dom.descEditor.innerHTML = step.descriptionHtml || ''; if (document.activeElement !== this.dom.descEditor) this.dom.descEditor.innerHTML = step.descriptionHtml || '';
this.dom.statusSelect.value = step.status || 'todo'; this.dom.statusSelect.value = step.status || 'todo';
this.updateAiButtonHints();
this.dom.hiddenToggle.querySelector('input').checked = Boolean(step.hidden); this.dom.hiddenToggle.querySelector('input').checked = Boolean(step.hidden);
this.dom.skippedToggle.querySelector('input').checked = Boolean(step.skipped); this.dom.skippedToggle.querySelector('input').checked = Boolean(step.skipped);
this.dom.forceNewPageToggle.querySelector('input').checked = Boolean(step.forceNewPage); this.dom.forceNewPageToggle.querySelector('input').checked = Boolean(step.forceNewPage);
@@ -852,6 +1122,7 @@ class GuideEditor {
if (token !== this.imageLoadToken) return; if (token !== this.imageLoadToken) return;
this.canvas.setImage(img, img.naturalWidth || img.width, img.naturalHeight || img.height); this.canvas.setImage(img, img.naturalWidth || img.width, img.naturalHeight || img.height);
this.canvas.setAnnotations(step.annotations || []); this.canvas.setAnnotations(step.annotations || []);
this.canvas.setFocusedView(step.focusedView);
this.canvas.setTool(this.currentTool); this.canvas.setTool(this.currentTool);
this.canvas.setZoom(this.currentZoom); this.canvas.setZoom(this.currentZoom);
}; };
@@ -883,7 +1154,7 @@ class GuideEditor {
dataset: { annId: ann.id }, dataset: { annId: ann.id },
style: { cursor: 'pointer', borderColor: selected ? 'var(--accent)' : '' }, style: { cursor: 'pointer', borderColor: selected ? 'var(--accent)' : '' },
}, },
el('div.row', {}, el('strong', {}, ann.type), el('span.muted', {}, ann.text || ann.value || '')), el('div.row', {}, el('strong', {}, ANNOTATION_TYPE_LABELS[ann.type] || ann.type), el('span.muted', {}, ann.text || ann.value || '')),
el('div.muted', {}, `${ann.x.toFixed(3)}, ${ann.y.toFixed(3)} · ${ann.w.toFixed(3)} × ${ann.h.toFixed(3)}`))); el('div.muted', {}, `${ann.x.toFixed(3)}, ${ann.y.toFixed(3)} · ${ann.w.toFixed(3)} × ${ann.h.toFixed(3)}`)));
} }
} }
@@ -898,7 +1169,7 @@ class GuideEditor {
const style = selected.style || {}; const style = selected.style || {};
const typeSelect = makeSelect(selected.type, [ const typeSelect = makeSelect(selected.type, [
'rect', 'oval', 'line', 'arrow', 'text', 'tooltip', 'number', 'blur', 'highlight', 'magnify', 'cursor', 'rect', 'oval', 'line', 'arrow', 'text', 'tooltip', 'number', 'blur', 'highlight', 'magnify', 'cursor',
].map((type) => ({ value: type, label: type }))); ].map((type) => ({ value: type, label: ANNOTATION_TYPE_LABELS[type] || type })));
const textInput = el('input', { type: 'text', value: selected.text || '', placeholder: 'Annotation text' }); const textInput = el('input', { type: 'text', value: selected.text || '', placeholder: 'Annotation text' });
const valueInput = el('input', { type: 'number', value: Number.isFinite(selected.value) ? selected.value : '', placeholder: 'Value' }); const valueInput = el('input', { type: 'number', value: Number.isFinite(selected.value) ? selected.value : '', placeholder: 'Value' });
const strokeInput = el('input', { type: 'color', value: style.stroke || '#E5484D' }); const strokeInput = el('input', { type: 'color', value: style.stroke || '#E5484D' });
@@ -932,31 +1203,38 @@ class GuideEditor {
this.emitMeta(); this.emitMeta();
}; };
const annSection = el('div', { className: 'annotation-editor-inner' }, const fields = new Set(ANNOTATION_FIELDS[selected.type] || []);
labeledRow('Type', typeSelect), const strokeLabel = (selected.type === 'text' || selected.type === 'number') ? 'Color' : 'Stroke';
labeledRow('Text', textInput), const typeLabel = ANNOTATION_TYPE_LABELS[selected.type] || selected.type;
labeledRow('Value', valueInput),
labeledRow('Stroke', strokeInput), const rows = [labeledRow('Type', typeSelect)];
labeledRow('Fill', fillInput), if (fields.has('text')) rows.push(labeledRow('Text', textInput));
labeledRow('Stroke width', strokeWidthInput), if (fields.has('value')) rows.push(labeledRow('Value', valueInput));
labeledRow('Font size', fontSizeInput), if (fields.has('stroke')) rows.push(labeledRow(strokeLabel, strokeInput));
labeledRow('Text color', textColorInput), if (fields.has('fill')) rows.push(labeledRow('Fill', fillInput));
labeledRow('Zoom', zoomInput), if (fields.has('strokeWidth')) rows.push(labeledRow('Stroke width', strokeWidthInput));
labeledRow('Radius', radiusInput), if (fields.has('fontSize')) rows.push(labeledRow('Font size', fontSizeInput));
labeledRow('Tail', tailInput), if (fields.has('textColor')) rows.push(labeledRow('Text color', textColorInput));
if (fields.has('zoom')) rows.push(labeledRow('Zoom', zoomInput));
if (fields.has('radius')) rows.push(labeledRow('Radius', radiusInput));
if (fields.has('tail')) rows.push(labeledRow('Tail', tailInput));
rows.push(
el('div.muted', {}, `Copy this style to every other "${typeLabel}" annotation:`),
el('div.row', {}, el('div.row', {},
el('button', { el('button', {
type: 'button', type: 'button',
onClick: () => { title: `Overwrite the style of every "${typeLabel}" annotation on this step with the style shown above.`,
this.canvas.deleteSelected(); onClick: () => this.applyStyleAcross('step'),
}, }, 'This step'),
}, 'Delete annotation'), el('button', {
), type: 'button',
el('div.row', {}, title: `Overwrite the style of every "${typeLabel}" annotation across all steps in this guide with the style shown above.`,
el('button', { type: 'button', title: 'Copy this style to every annotation of the same type in this step', onClick: () => this.applyStyleAcross('step') }, 'Style → step'), onClick: () => this.applyStyleAcross('guide'),
el('button', { type: 'button', title: 'Copy this style to every annotation of the same type in the whole guide', onClick: () => this.applyStyleAcross('guide') }, 'Style → guide'), }, 'Entire guide'),
), ),
); );
const annSection = el('div', { className: 'annotation-editor-inner' }, ...rows);
this.dom.annotationEditor.append(annSection); this.dom.annotationEditor.append(annSection);
typeSelect.addEventListener('change', () => { typeSelect.addEventListener('change', () => {
@@ -1019,13 +1297,6 @@ class GuideEditor {
}); });
} }
renderGuidePanel() {
if (!this.guide) return;
this.dom.linkedBtn.textContent = this.guide.linkedSource ? 'Linked guide' : 'Local guide';
this.dom.linkedBtn.disabled = !this.guide.linkedSource;
this.dom.snapshotBtn.textContent = 'Snapshot';
}
defaultStyleForTool(tool) { defaultStyleForTool(tool) {
switch (tool) { switch (tool) {
case 'highlight': return { fill: '#ffe066', stroke: '#ffbf00', strokeWidth: 1 }; case 'highlight': return { fill: '#ffe066', stroke: '#ffbf00', strokeWidth: 1 };
@@ -1067,7 +1338,8 @@ class GuideEditor {
pushCanvasHistory(recordOrLabel = 'change') { pushCanvasHistory(recordOrLabel = 'change') {
if (!this.currentStep) return; if (!this.currentStep) return;
const record = recordOrLabel && typeof recordOrLabel === 'object' && recordOrLabel.step const record = recordOrLabel && typeof recordOrLabel === 'object'
&& (recordOrLabel.step || recordOrLabel.type === 'delete-step')
? recordOrLabel ? recordOrLabel
: { step: clone(this.currentStep) }; : { step: clone(this.currentStep) };
this.canvasHistory.push(record); this.canvasHistory.push(record);
@@ -1108,27 +1380,39 @@ class GuideEditor {
} }
async undo() { async undo() {
if (!this.currentStep) return;
if (!this.canvasHistory.length) { if (!this.canvasHistory.length) {
this.onToast('Nothing to undo.'); this.onToast('Nothing to undo.');
return; return;
} }
const previous = this.canvasHistory.pop();
if (previous.type === 'delete-step') {
await this.restoreDeletedSteps(previous);
this.canvasFuture.push(previous);
this.renderAll();
return;
}
if (!this.currentStep) return;
const current = await this.snapshotCurrentStep(true); const current = await this.snapshotCurrentStep(true);
if (current) this.canvasFuture.push(current); if (current) this.canvasFuture.push(current);
const previous = this.canvasHistory.pop();
await this.restoreHistoryRecord(previous); await this.restoreHistoryRecord(previous);
this.renderAll(); this.renderAll();
} }
async redo() { async redo() {
if (!this.currentStep) return;
if (!this.canvasFuture.length) { if (!this.canvasFuture.length) {
this.onToast('Nothing to redo.'); this.onToast('Nothing to redo.');
return; return;
} }
const next = this.canvasFuture.pop();
if (next.type === 'delete-step') {
await this.deleteStepsAgain(next);
this.canvasHistory.push(next);
this.renderAll();
return;
}
if (!this.currentStep) return;
const current = await this.snapshotCurrentStep(true); const current = await this.snapshotCurrentStep(true);
if (current) this.canvasHistory.push(current); if (current) this.canvasHistory.push(current);
const next = this.canvasFuture.pop();
await this.restoreHistoryRecord(next); await this.restoreHistoryRecord(next);
this.renderAll(); this.renderAll();
} }
@@ -1189,24 +1473,48 @@ class GuideEditor {
async saveAll() { async saveAll() {
if (this.currentStep) await this.flushStep(); if (this.currentStep) await this.flushStep();
if (this.guide) await this.flushGuide(); if (this.guide) await this.flushGuide();
if (this.guide && this.guide.linkedSource) {
const result = await api.archive.saveLinked({ guideId: this.guideId, force: false });
if (result.saved) {
this.guide.linkedSource.lastSavedAt = new Date().toISOString();
this.onToast('Saved and synced to linked archive.');
} else {
this.onToast('Saved locally, but the linked archive is locked by another session.', { error: true });
}
return;
}
this.onToast('Saved.'); this.onToast('Saved.');
} }
async createSnapshot() { async createSnapshot() {
if (!this.guideId) return; if (!this.guideId) return;
await api.snapshots.create({ guideId: this.guideId, label: 'manual' }); const label = await dialogs.promptText({
title: 'Create snapshot',
label: 'Snapshot name',
placeholder: 'manual',
});
if (label == null) return;
if (this.currentStep) await this.flushStep();
if (this.guide) await this.flushGuide();
await api.snapshots.create({ guideId: this.guideId, label: label.trim() || 'manual' });
this.onToast('Snapshot created.'); this.onToast('Snapshot created.');
} }
async selectStep(stepId) { async selectStep(stepId) {
if (!this.stepMap.has(stepId)) return; if (!this.stepMap.has(stepId)) return;
// Persist any unsaved edits on the outgoing step before switching, so a
// later guide-wide reload (e.g. applyStyleAcross('guide')) doesn't
// discard them by re-fetching a stale on-disk copy.
if (this.pendingSave) await this.flushStep();
this.selectedStepId = stepId; this.selectedStepId = stepId;
this.selectedAnnotationId = null; this.selectedAnnotationId = null;
this.canvas.select(null); this.canvas.select(null);
this.syncStepFields(); this.syncStepFields();
this.syncFocusedControls();
this.renderStepList(); this.renderStepList();
this.renderCanvas(); this.renderCanvas();
this.renderAnnotationPanel(); this.renderAnnotationPanel();
this.renderBlocksPanel();
this.emitMeta(); this.emitMeta();
} }
@@ -1256,6 +1564,55 @@ class GuideEditor {
this.onToast(parent ? 'Substep added.' : 'Step added.'); this.onToast(parent ? 'Substep added.' : 'Step added.');
} }
/** All step ids whose ancestor chain leads back to `stepId`. */
getStepDescendantIds(stepId) {
const result = [];
const queue = [stepId];
while (queue.length) {
const current = queue.shift();
for (const s of this.steps) {
if (s.parentStepId === current) {
result.push(s.stepId);
queue.push(s.stepId);
}
}
}
return result;
}
async makeSubstepOf(stepId, targetStepId) {
const step = this.stepMap.get(stepId);
const target = this.stepMap.get(targetStepId);
if (!step || !target) return;
const numbers = stepNumberMap(this.steps);
const subtreeIds = new Set([stepId, ...this.getStepDescendantIds(stepId)]);
if (subtreeIds.has(targetStepId)) {
this.onToast('A step cannot be made a substep of itself or one of its own substeps.', { error: true });
return;
}
if (step.parentStepId === targetStepId) {
this.onToast(`${step.title || 'Untitled step'}” is already a substep of step ${numbers.get(targetStepId)}.`);
return;
}
step.parentStepId = targetStepId;
await api.step.save({ guideId: this.guideId, step });
// Move the step (with its own substeps) to sit right after the target
// step's existing substeps, so it becomes the target's last substep.
const order = this.steps.map((s) => s.stepId);
const remaining = order.filter((id) => !subtreeIds.has(id));
const targetSubtree = new Set([targetStepId, ...this.getStepDescendantIds(targetStepId)]);
let insertAt = remaining.indexOf(targetStepId) + 1;
while (insertAt < remaining.length && targetSubtree.has(remaining[insertAt])) insertAt++;
const movedBlock = order.filter((id) => subtreeIds.has(id));
remaining.splice(insertAt, 0, ...movedBlock);
await api.step.reorder({ guideId: this.guideId, order: remaining });
await this.reload(stepId);
this.onToast(`${step.title || 'Untitled step'}” is now a substep of step ${numbers.get(targetStepId)}.`);
}
async duplicateSelectedStep() { async duplicateSelectedStep() {
const step = this.currentStep; const step = this.currentStep;
if (!step) return; if (!step) return;
@@ -1282,12 +1639,16 @@ class GuideEditor {
if (!step) return; if (!step) return;
const ok = await confirmDialog(`Delete “${step.title || 'Untitled step'}”?`, { danger: true, okLabel: 'Delete' }); const ok = await confirmDialog(`Delete “${step.title || 'Untitled step'}”?`, { danger: true, okLabel: 'Delete' });
if (!ok) return; if (!ok) return;
if (this.pendingSave) await this.flushStep();
const order = this.steps.map((s) => s.stepId);
const entry = await this.snapshotStepForDeletion(step);
await api.step.delete({ guideId: this.guideId, stepId: step.stepId }); await api.step.delete({ guideId: this.guideId, stepId: step.stepId });
this.pushCanvasHistory({ type: 'delete-step', steps: [entry], order });
const next = this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) + 1] const next = this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) + 1]
|| this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) - 1] || this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) - 1]
|| null; || null;
await this.reload(next && next.stepId); await this.reload(next && next.stepId);
this.onToast('Step deleted.'); this.onToast('Step deleted. Press Ctrl+Z to undo.');
} }
async moveSelectedStep(delta) { async moveSelectedStep(delta) {
@@ -1327,18 +1688,18 @@ class GuideEditor {
async openCaptureMenu(event) { async openCaptureMenu(event) {
const rect = event.target.getBoundingClientRect(); const rect = event.target.getBoundingClientRect();
const session = (await api.capture.state())?.active; const session = (await api.capture.state())?.active;
contextMenu(rect.left, rect.bottom + 4, [ const items = [
{ label: 'Capture full screen', action: () => this.captureStep('fullscreen') }, { label: 'Capture full screen', action: () => this.captureStep('fullscreen') },
{ label: 'Capture window', action: () => this.captureStep('window') }, { label: 'Capture window', action: () => this.captureStep('window') },
{ label: 'Capture region…', action: () => this.captureStep('region') }, { label: 'Capture region…', action: () => this.captureStep('region') },
'sep', 'sep',
{ label: 'Paste image as step', action: () => this.pasteClipboardStep() }, { label: 'Paste image as step', action: () => this.pasteClipboardStep() },
{ label: 'Import images…', action: () => this.importImageSteps() }, { label: 'Import images…', action: () => this.importImageSteps() },
'sep', ];
session if (!session) {
? { label: 'Finish capture session', action: () => this.finishCaptureSession() } items.push('sep', { label: 'Start capture session (hotkey)', action: () => this.startCaptureSession() });
: { label: 'Start capture session (hotkey)', action: () => this.startCaptureSession() }, }
]); contextMenu(rect.left, rect.bottom + 4, items);
} }
async pasteClipboardStep() { async pasteClipboardStep() {
@@ -1394,6 +1755,22 @@ class GuideEditor {
}); });
} }
async openGuideInfo() {
if (!this.guide) return;
await dialogs.showGuideInfoDialog({
values: {
...this.guide.metadata,
description: htmlToPlainText(this.guide.descriptionHtml || ''),
},
onSave: async ({ description, ...metadata }) => {
this.guide.metadata = metadata;
this.guide.descriptionHtml = textToHtml(description);
await api.guide.save({ guide: this.guide });
this.onToast('Guide information saved.');
},
});
}
openShortcutsHelp() { openShortcutsHelp() {
dialogs.showShortcutsDialog(); dialogs.showShortcutsDialog();
} }
@@ -1446,16 +1823,11 @@ class GuideEditor {
this.emitMeta(); this.emitMeta();
} }
async finishCaptureSession() {
await api.capture.session({ action: 'finish', guideId: this.guideId });
this.onToast('Capture session finished.');
this.emitMeta();
}
async openSettings() { async openSettings() {
const settings = await api.settings.all(); const settings = await api.settings.all();
const placeholders = await api.settings.globalPlaceholders(); const placeholders = await api.settings.globalPlaceholders();
await dialogs.showSettingsDialog({ await dialogs.showSettingsDialog({
api,
settings, settings,
placeholders, placeholders,
onSave: async (next) => { onSave: async (next) => {
@@ -1463,6 +1835,7 @@ class GuideEditor {
await api.settings.set({ keyPath: 'spellcheck', value: next.spellcheck }); await api.settings.set({ keyPath: 'spellcheck', value: next.spellcheck });
await api.settings.set({ keyPath: 'capture', value: next.capture }); await api.settings.set({ keyPath: 'capture', value: next.capture });
await api.settings.set({ keyPath: 'editor', value: next.editor }); await api.settings.set({ keyPath: 'editor', value: next.editor });
await api.settings.set({ keyPath: 'ai', value: next.ai });
await api.settings.set({ keyPath: 'exports', value: next.exports }); await api.settings.set({ keyPath: 'exports', value: next.exports });
await api.settings.set({ keyPath: 'backups', value: next.backups }); await api.settings.set({ keyPath: 'backups', value: next.backups });
await api.settings.setGlobalPlaceholders(next.placeholders || {}); await api.settings.setGlobalPlaceholders(next.placeholders || {});
@@ -1471,7 +1844,11 @@ class GuideEditor {
} }
async openExportDialog() { async openExportDialog() {
const formats = (await api.export.formats()).map((id) => ({ id, label: id.replace(/-/g, ' ') })); const formats = (await api.export.formats()).map((format) => (
typeof format === 'string'
? { id: format, label: format.replace(/-/g, ' ') }
: format
));
const templatesByFormat = {}; const templatesByFormat = {};
for (const format of formats) { for (const format of formats) {
templatesByFormat[format.id] = await api.templates.list({ format: format.id }); templatesByFormat[format.id] = await api.templates.list({ format: format.id });
@@ -1598,8 +1975,12 @@ class GuideEditor {
} }
async currentStepImageToBase64(step = this.currentStep) { async currentStepImageToBase64(step = this.currentStep) {
return this.stepImageToBase64(step, 'working');
}
async stepImageToBase64(step, which = 'working') {
if (!step || !step.image) return null; if (!step || !step.image) return null;
const file = await api.step.imagePath({ guideId: this.guideId, stepId: step.stepId, which: 'working' }); const file = await api.step.imagePath({ guideId: this.guideId, stepId: step.stepId, which });
if (!file) return null; if (!file) return null;
return new Promise((resolve) => { return new Promise((resolve) => {
const img = new Image(); const img = new Image();
@@ -1617,6 +1998,62 @@ class GuideEditor {
}); });
} }
/** Snapshot a step (and its images, if any) before deletion so it can be undone. */
async snapshotStepForDeletion(step) {
const position = this.steps.findIndex((s) => s.stepId === step.stepId);
const childIds = this.steps.filter((s) => s.parentStepId === step.stepId).map((s) => s.stepId);
let images = null;
if (step.image) {
const [original, working] = await Promise.all([
this.stepImageToBase64(step, 'original'),
this.stepImageToBase64(step, 'working'),
]);
images = { original, working };
}
return { step: clone(step), position, childIds, images };
}
/** Undo a 'delete-step' history record: recreate the deleted steps and their order. */
async restoreDeletedSteps(record) {
for (const entry of record.steps) {
await api.step.restore({
guideId: this.guideId,
step: entry.step,
originalBase64: entry.images?.original?.base64 || null,
workingBase64: entry.images?.working?.base64 || null,
position: entry.position,
});
}
await api.step.reorder({ guideId: this.guideId, order: record.order });
this.saveStepDebounced.cancel();
this.pendingSave = false;
await this.reload(record.steps[0].step.stepId);
for (const entry of record.steps) {
for (const childId of entry.childIds) {
const child = this.stepMap.get(childId);
if (child && child.parentStepId !== entry.step.stepId) {
child.parentStepId = entry.step.stepId;
await api.step.save({ guideId: this.guideId, step: child });
}
}
}
if (record.steps.some((entry) => entry.childIds.length)) await this.reload(record.steps[0].step.stepId);
this.onToast(`Restored ${record.steps.length} step${record.steps.length === 1 ? '' : 's'}.`);
}
/** Redo of an undone 'delete-step' record: delete those steps again. */
async deleteStepsAgain(record) {
for (const entry of record.steps) {
await api.step.delete({ guideId: this.guideId, stepId: entry.step.stepId });
}
const deletedIds = new Set(record.steps.map((entry) => entry.step.stepId));
const remaining = record.order.filter((id) => !deletedIds.has(id));
this.saveStepDebounced.cancel();
this.pendingSave = false;
await this.reload(remaining[0] || null);
this.onToast(`Deleted ${record.steps.length} step${record.steps.length === 1 ? '' : 's'}.`);
}
async onCanvasChange(annotations) { async onCanvasChange(annotations) {
const step = this.currentStep; const step = this.currentStep;
if (!step) return; if (!step) return;
@@ -1727,12 +2164,16 @@ class GuideEditor {
case 'insertOrderedList': case 'insertOrderedList':
document.execCommand('insertOrderedList'); document.execCommand('insertOrderedList');
break; break;
case 'formatBlock': case 'formatBlock': {
document.execCommand('formatBlock', false, block || 'blockquote'); const want = block || 'blockquote';
const current = document.queryCommandValue('formatBlock').toLowerCase();
document.execCommand('formatBlock', false, current === want ? 'p' : want);
break; break;
}
case 'createLink': { case 'createLink': {
const url = window.prompt('Link URL'); const selectedText = window.getSelection().toString();
if (url) document.execCommand('createLink', false, url); const text = selectedText || 'Text';
document.execCommand('insertText', false, `[${text}](Link)`);
break; break;
} }
case 'removeFormat': case 'removeFormat':
@@ -1746,6 +2187,7 @@ class GuideEditor {
this.pendingSave = true; this.pendingSave = true;
this.saveStepDebounced(); this.saveStepDebounced();
} }
this.updateToolbarState();
} }
onDocumentKeyDown(e) { onDocumentKeyDown(e) {
@@ -1832,7 +2274,7 @@ class GuideEditor {
} }
return; return;
} }
if (e.key === 'Delete' && this.selectedAnnotationId) { if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedAnnotationId) {
e.preventDefault(); e.preventDefault();
if (this.canvas.deleteSelected()) this.saveStepDebounced(); if (this.canvas.deleteSelected()) this.saveStepDebounced();
return; return;
+139 -1
View File
@@ -90,6 +90,35 @@ button.tool.active {
border-color: var(--accent); border-color: var(--accent);
color: var(--accent-fg); color: var(--accent-fg);
} }
button.ai {
padding: 5px 10px;
border-color: color-mix(in srgb, var(--accent) 38%, var(--border));
background: color-mix(in srgb, var(--accent) 10%, var(--panel-solid));
color: var(--accent-strong);
font-weight: 650;
letter-spacing: 0.02em;
}
button.ai:hover { background: color-mix(in srgb, var(--accent) 16%, var(--panel-2)); }
button.ai:disabled { opacity: 0.6; }
button:disabled { opacity: 0.6; cursor: default; }
button.loading {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
}
.spinner {
display: inline-block;
width: 13px;
height: 13px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
opacity: 0.85;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
input, select, textarea { input, select, textarea {
font: inherit; font: inherit;
@@ -487,6 +516,11 @@ kbd {
margin-bottom: 8px; margin-bottom: 8px;
} }
.rich-toolbar button { padding: 4px 8px; font-size: 12px; } .rich-toolbar button { padding: 4px 8px; font-size: 12px; }
.rich-toolbar button.active {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-fg);
}
.rich-editor { .rich-editor {
min-height: 110px; min-height: 110px;
max-height: 220px; max-height: 220px;
@@ -509,6 +543,12 @@ kbd {
border-radius: 10px; border-radius: 10px;
background: var(--panel-2); background: var(--panel-2);
} }
.rich-editor blockquote {
margin: 6px 0;
padding: 2px 0 2px 12px;
border-left: 3px solid var(--accent);
color: var(--muted);
}
.block-card, .block-card,
.annotation-editor-inner { .annotation-editor-inner {
@@ -518,12 +558,17 @@ kbd {
} }
.block-card { .block-card {
border: 1px solid var(--border); border: 1px solid var(--border);
border-left: 4px solid var(--border);
border-radius: 12px; border-radius: 12px;
padding: 10px; padding: 10px;
background: var(--panel-solid); background: var(--panel-solid);
} }
.block-card[draggable="true"] { cursor: grab; } .block-card[draggable="true"] { cursor: grab; }
.block-card[draggable="true"]:active { cursor: grabbing; } .block-card[draggable="true"]:active { cursor: grabbing; }
.block-card[data-level="info"] { border-left-color: #3b82f6; }
.block-card[data-level="success"] { border-left-color: #10b981; }
.block-card[data-level="warn"] { border-left-color: #f59e0b; }
.block-card[data-level="error"] { border-left-color: #ef4444; }
.annotation-list { .annotation-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -564,8 +609,82 @@ fieldset legend {
} }
.placeholder-row input { width: 100%; } .placeholder-row input { width: 100%; }
.hotkey-input {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 190px;
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel-2);
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
.hotkey-input:hover {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
}
.hotkey-input:focus-visible,
.hotkey-input.recording {
outline: none;
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, var(--panel-2));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
}
.hotkey-keys {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 22px;
flex: 1;
}
.hotkey-keys kbd {
min-width: 22px;
justify-content: center;
padding: 3px 7px;
font-size: 11px;
font-weight: 650;
background: var(--panel-solid);
box-shadow: 0 1px 0 0 color-mix(in srgb, var(--text) 8%, transparent);
}
.hotkey-sep {
color: var(--muted);
font-size: 11px;
}
.hotkey-placeholder {
color: var(--muted);
font-size: 12px;
}
.hotkey-clear {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: var(--muted);
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.hotkey-clear:hover {
background: var(--panel-solid);
color: var(--text);
}
.hotkey-clear[hidden] {
display: none;
}
.quick-actions { .quick-actions {
width: min(760px, 92vw); width: min(760px, 92vw);
display: flex;
flex-direction: column;
gap: 8px;
} }
.quick-actions input { .quick-actions input {
width: 100%; width: 100%;
@@ -573,7 +692,6 @@ fieldset legend {
padding: 11px 12px; padding: 11px 12px;
} }
.qa-results { .qa-results {
margin-top: 10px;
max-height: 360px; max-height: 360px;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
@@ -643,6 +761,7 @@ fieldset legend {
color: var(--muted); color: var(--muted);
line-height: 1.5; line-height: 1.5;
} }
.ai-status.error { color: var(--danger); }
#modal-root:not(:empty) { #modal-root:not(:empty) {
position: fixed; position: fixed;
@@ -731,6 +850,25 @@ fieldset legend {
} }
.ctx-menu .mi:hover { background: var(--panel-2); } .ctx-menu .mi:hover { background: var(--panel-2); }
.ctx-menu .mi.danger { color: var(--danger); } .ctx-menu .mi.danger { color: var(--danger); }
.ctx-menu .mi.disabled {
color: var(--muted);
cursor: default;
}
.ctx-menu .mi.disabled:hover { background: none; }
.ctx-menu .mi.has-submenu {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.ctx-menu .mi .submenu-arrow {
color: var(--muted);
font-size: 12px;
}
.ctx-submenu {
max-height: min(360px, calc(100vh - 24px));
overflow-y: auto;
}
.ctx-menu hr { .ctx-menu hr {
margin: 6px 0; margin: 6px 0;
border: 0; border: 0;
+83 -4
View File
@@ -34,6 +34,27 @@ function clearNode(node) {
while (node.firstChild) node.removeChild(node.firstChild); while (node.firstChild) node.removeChild(node.firstChild);
} }
/**
* Toggle a button into/out of a busy state: disables it and swaps its label
* for a spinner + `label` (e.g. "Exporting…"), restoring the original label
* afterwards. Used for actions that block on a slow main-process call.
*/
function setButtonLoading(btn, loading, label) {
if (loading) {
if (btn.dataset.origLabel === undefined) btn.dataset.origLabel = btn.textContent;
btn.disabled = true;
btn.classList.add('loading');
clearNode(btn);
btn.append(el('span.spinner'), label || btn.dataset.origLabel);
} else {
btn.disabled = false;
btn.classList.remove('loading');
clearNode(btn);
btn.append(btn.dataset.origLabel ?? '');
delete btn.dataset.origLabel;
}
}
function debounce(fn, ms) { function debounce(fn, ms) {
let t = null; let t = null;
const wrapped = (...args) => { const wrapped = (...args) => {
@@ -119,15 +140,55 @@ function promptDialog(title, { value = '', label = 'Name' } = {}) {
}); });
} }
/** Context menu at (x, y); items: [{label, danger, action}] or 'sep'. */ /**
* Context menu at (x, y).
* items: [{label, danger, action}] | [{label, submenu}] | 'sep'.
* `submenu` is an array (or a function returning one) of the same item
* shapes, shown in a scrollable panel beside the item on hover.
*/
function contextMenu(x, y, items) { function contextMenu(x, y, items) {
document.querySelectorAll('.ctx-menu').forEach((n) => n.remove()); document.querySelectorAll('.ctx-menu').forEach((n) => n.remove());
const menu = el('div.ctx-menu', { style: { left: `${x}px`, top: `${y}px` } }); const menu = el('div.ctx-menu', { style: { left: `${x}px`, top: `${y}px` } });
let openSubmenu = null;
const closeSubmenu = () => {
if (openSubmenu) { openSubmenu.remove(); openSubmenu = null; }
};
const closeAll = () => { closeSubmenu(); menu.remove(); };
for (const item of items) { for (const item of items) {
if (item === 'sep') { menu.append(el('hr')); continue; } if (item === 'sep') { menu.append(el('hr', { onMouseEnter: closeSubmenu })); continue; }
if (item.submenu) {
const mi = el('div.mi.has-submenu', {
onMouseEnter: () => {
closeSubmenu();
const subItems = typeof item.submenu === 'function' ? item.submenu() : item.submenu;
const sub = el('div.ctx-menu.ctx-submenu');
if (!subItems.length) {
sub.append(el('div.mi.disabled', {}, 'Nothing else to choose'));
}
for (const subItem of subItems) {
if (subItem === 'sep') { sub.append(el('hr')); continue; }
sub.append(el('div.mi', {
className: `mi${subItem.danger ? ' danger' : ''}`,
onClick: () => { closeAll(); subItem.action(); },
}, subItem.label));
}
document.body.append(sub);
const miRect = mi.getBoundingClientRect();
sub.style.left = `${miRect.right + 2}px`;
sub.style.top = `${miRect.top}px`;
const subRect = sub.getBoundingClientRect();
if (subRect.right > innerWidth) sub.style.left = `${Math.max(6, miRect.left - subRect.width - 2)}px`;
if (subRect.bottom > innerHeight) sub.style.top = `${Math.max(6, innerHeight - subRect.height - 6)}px`;
openSubmenu = sub;
},
}, el('span', {}, item.label), el('span.submenu-arrow', {}, ''));
menu.append(mi);
continue;
}
menu.append(el('div.mi', { menu.append(el('div.mi', {
className: `mi${item.danger ? ' danger' : ''}`, className: `mi${item.danger ? ' danger' : ''}`,
onClick: () => { menu.remove(); item.action(); }, onMouseEnter: closeSubmenu,
onClick: () => { closeAll(); item.action(); },
}, item.label)); }, item.label));
} }
document.body.append(menu); document.body.append(menu);
@@ -135,7 +196,7 @@ function contextMenu(x, y, items) {
if (rect.right > innerWidth) menu.style.left = `${innerWidth - rect.width - 6}px`; if (rect.right > innerWidth) menu.style.left = `${innerWidth - rect.width - 6}px`;
if (rect.bottom > innerHeight) menu.style.top = `${innerHeight - rect.height - 6}px`; if (rect.bottom > innerHeight) menu.style.top = `${innerHeight - rect.height - 6}px`;
setTimeout(() => { setTimeout(() => {
document.addEventListener('click', () => menu.remove(), { once: true }); document.addEventListener('click', () => closeAll(), { once: true });
}, 0); }, 0);
} }
@@ -147,3 +208,21 @@ function fmtDate(iso) {
const escapeHtml = (s) => String(s ?? '') const escapeHtml = (s) => String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/** Inverse of textToHtml, for loading sanitized description HTML into a plain textarea. */
function htmlToPlainText(html) {
return String(html || '')
.replace(/<(br|\/p|\/div|\/li|\/h[1-6])\s*\/?>/gi, '\n')
.replace(/<[^>]*>/g, '')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, '\'').replace(/&amp;/g, '&')
.replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim();
}
/** Plain textarea text -> sanitizer-allowed paragraph HTML (blank line = new paragraph). */
function textToHtml(text) {
const trimmed = String(text || '').trim();
if (!trimmed) return '';
return trimmed.split(/\n{2,}/)
.map((para) => `<p>${escapeHtml(para).replace(/\n/g, '<br>')}</p>`)
.join('');
}
+8
View File
@@ -340,6 +340,14 @@ async function createElectronHost(onEvent) {
if (!win.isDestroyed()) win.destroy(); if (!win.isDestroyed()) win.destroy();
throw err; throw err;
} }
// The worker is a hidden renderer, so on battery Windows would EcoQoS-throttle
// it and starve frame sampling. Clear that on its own OS process before it
// starts streaming. Best-effort; no-op off Windows.
try {
// eslint-disable-next-line global-require
const { keepProcessesResponsive } = require('./win-power');
if (!win.isDestroyed()) keepProcessesResponsive([win.webContents.getOSProcessId()]);
} catch { /* throttling tweak is optional */ }
return { return {
send(msg) { send(msg) {
if (!win.isDestroyed()) win.webContents.send('capture-worker:command', msg); if (!win.isDestroyed()) win.webContents.send('capture-worker:command', msg);
+654
View File
@@ -0,0 +1,654 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync, execFile } = require('node:child_process');
const {
DEFAULT_CAPTURE_TITLES,
buildCaptureTitle,
normalizeOllamaHost,
normalizeAiPatch,
buildAiPrompt,
applyAiPatchToStep,
displayText,
normalizeWhitespace,
} = require('../core/text-intel');
const DEFAULT_TITLE_VALUES = new Set(Object.values(DEFAULT_CAPTURE_TITLES).concat(['Capture']));
const OCR_CROP = {
width: 420,
height: 220,
};
function hasBinary(name) {
try {
execFileSync('which', [name], { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
function clamp(v, min, max) {
return Math.min(max, Math.max(min, v));
}
function modelLooksVisionCapable(model) {
const clean = normalizeWhitespace(model).toLowerCase();
if (!clean) return false;
return clean.includes('vision')
|| clean.includes('llava')
|| clean.includes('gemma4')
|| /(^|[^a-z0-9])qwen[23](?:\.[0-9]+)?vl([^a-z0-9]|$)/.test(clean);
}
let createWorkerImpl = null;
function loadCreateWorker() {
if (createWorkerImpl) return createWorkerImpl;
// OCR is optional at startup; lazy-load it so the app can still boot when
// the dependency has not been installed yet.
// eslint-disable-next-line global-require
({ createWorker: createWorkerImpl } = require('tesseract.js'));
return createWorkerImpl;
}
class TextIntelService {
constructor({
store,
settings,
getWindow = () => null,
dataDir,
fetchImpl = global.fetch,
screenApi = null,
}) {
this.store = store;
this.settings = settings;
this.getWindow = getWindow;
this.dataDir = dataDir;
this.fetch = fetchImpl;
this.screen = screenApi;
this.worker = null;
this.workerPromise = null;
this.workerQueue = Promise.resolve();
this.ocrDataDir = path.join(dataDir, 'ocr', 'eng');
this.modelCapabilityCache = new Map();
}
async shutdown() {
if (this.worker) {
try {
await this.worker.terminate();
} catch {
// best effort
}
this.worker = null;
this.workerPromise = null;
}
}
ensureLangData() {
const packageDir = path.dirname(require.resolve('@tesseract.js-data/eng/package.json'));
const source = path.join(packageDir, '4.0.0_best_int', 'eng.traineddata.gz');
const targetDir = this.ocrDataDir;
const target = path.join(targetDir, 'eng.traineddata.gz');
if (!fs.existsSync(target)) {
fs.mkdirSync(targetDir, { recursive: true });
fs.copyFileSync(source, target);
}
return targetDir;
}
async getWorker() {
if (this.workerPromise) return this.workerPromise;
this.workerPromise = (async () => {
const workerFactory = loadCreateWorker();
const langPath = this.ensureLangData();
const worker = await workerFactory('eng', 1, {
langPath,
});
await worker.setParameters({
preserve_interword_spaces: '1',
});
this.worker = worker;
return worker;
})();
this.workerPromise.catch(() => {
this.workerPromise = null;
});
return this.workerPromise;
}
async recognizeCrop(image, rect = null) {
const worker = await this.getWorker();
const cropped = rect ? image.crop(rect) : image;
const buffer = cropped.toPNG();
const result = await worker.recognize(buffer);
const text = String(result?.data?.text || '').trim();
return {
text,
confidence: Number.isFinite(result?.data?.confidence) ? result.data.confidence : null,
raw: result,
};
}
cropRectForPoint(frame, clickPos, { width = OCR_CROP.width, height = OCR_CROP.height } = {}) {
if (!frame || !frame.size) return null;
const bounds = frame.display.bounds || { x: 0, y: 0, width: frame.size.width, height: frame.size.height };
const scaleX = frame.size.width / bounds.width;
const scaleY = frame.size.height / bounds.height;
const point = clickPos || {
x: bounds.x + bounds.width / 2,
y: bounds.y + bounds.height / 2,
};
const centerX = (point.x - bounds.x) * scaleX;
const centerY = (point.y - bounds.y) * scaleY;
const rectW = Math.max(1, Math.round(width * scaleX));
const rectH = Math.max(1, Math.round(height * scaleY));
const rect = {
x: Math.round(centerX - rectW / 2),
y: Math.round(centerY - rectH / 2),
width: rectW,
height: rectH,
};
rect.x = clamp(rect.x, 0, Math.max(0, frame.size.width - rect.width));
rect.y = clamp(rect.y, 0, Math.max(0, frame.size.height - rect.height));
rect.width = clamp(rect.width, 1, frame.size.width);
rect.height = clamp(rect.height, 1, frame.size.height);
return rect;
}
async ocrAroundClick(frame, clickPos) {
if (!frame || !frame.image) return { text: '', confidence: null };
// Use a full-width horizontal strip at the click height. This preserves complete
// link text (e.g. "Oracle | Cloud Applications and Cloud Platform") rather than
// cropping through it when the element spans more than the 420 px default width.
const bounds = frame.display?.bounds || { x: 0, y: 0, width: frame.size.width, height: frame.size.height };
const rect = this.cropRectForPoint(frame, clickPos, {
width: bounds.width, // full display width → full image width after DPI scaling
height: 100, // ~2 lines tall, enough context without too much noise
});
try {
return await this.recognizeCrop(frame.image, rect);
} catch {
return { text: '', confidence: null };
}
}
async collectForegroundWindowContext(osPoint = null) {
try {
if (process.platform === 'win32') return this.collectWindowsWindowContext(osPoint);
if (process.platform === 'darwin') return this.collectMacWindowContext();
if (process.platform === 'linux') return this.collectLinuxWindowContext();
} catch {
// best effort only
}
return { appName: '', windowTitle: '' };
}
async collectWindowsWindowContext(osPoint = null) {
const hasPoint = osPoint && Number.isFinite(osPoint.x) && Number.isFinite(osPoint.y);
const clickX = hasPoint ? Number(osPoint.x) : 0;
const clickY = hasPoint ? Number(osPoint.y) : 0;
const script = `
$clickX = ${clickX};
$clickY = ${clickY};
$elementLabel = '';
$elementRole = '';
$elementClass = '';
$elementProcessId = 0;
$elementValue = '';
if (${hasPoint ? '$true' : '$false'}) {
try {
Add-Type -AssemblyName UIAutomationClient,UIAutomationTypes,WindowsBase | Out-Null
$point = New-Object System.Windows.Point($clickX, $clickY);
$element = [System.Windows.Automation.AutomationElement]::FromPoint($point);
if ($element) {
$current = $element.Current;
$elementLabel = $current.Name;
$elementRole = $current.LocalizedControlType;
$elementClass = $current.ClassName;
$elementProcessId = $current.ProcessId;
try {
$valPattern = [System.Windows.Automation.ValuePattern]::Pattern;
if ($element.GetSupportedPatterns() -contains $valPattern) {
$elementValue = $element.GetCurrentPattern($valPattern).Current.Value;
}
} catch { }
}
} catch { }
}
Add-Type @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public static class Win32 {
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
}
"@;
$hWnd = [Win32]::GetForegroundWindow();
$sb = New-Object System.Text.StringBuilder 512;
[void][Win32]::GetWindowText($hWnd, $sb, $sb.Capacity);
$pid = 0;
[void][Win32]::GetWindowThreadProcessId($hWnd, [ref]$pid);
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue | Select-Object -First 1;
$out = [ordered]@{
appName = if ($proc) { $proc.ProcessName } else { '' };
windowTitle = $sb.ToString();
elementLabel = $elementLabel;
elementRole = $elementRole;
elementClass = $elementClass;
elementValue = $elementValue;
elementProcessId = $elementProcessId;
pid = $pid;
};
$out | ConvertTo-Json -Compress;
`;
return new Promise(resolve => {
execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
encoding: 'utf8',
timeout: 4000,
windowsHide: true,
}, (err, stdout) => {
if (err) { resolve({}); return; }
try { resolve(JSON.parse(stdout.trim() || '{}')); }
catch { resolve({}); }
});
});
}
collectMacWindowContext() {
const script = `
set appName to ""
set windowTitle to ""
tell application "System Events"
try
set frontApp to first application process whose frontmost is true
set appName to name of frontApp
try
set windowTitle to name of front window of frontApp
end try
end try
end tell
return appName & linefeed & windowTitle
`;
const result = execFileSync('osascript', ['-e', script], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 1200,
}).trimEnd();
const [appName = '', windowTitle = ''] = result.split(/\r?\n/);
return { appName, windowTitle };
}
collectLinuxWindowContext() {
if (!hasBinary('xprop')) return { appName: '', windowTitle: '' };
const active = execFileSync('xprop', ['-root', '_NET_ACTIVE_WINDOW'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 1200,
});
const activeMatch = active.match(/window id # (0x[0-9a-fA-F]+)/);
if (!activeMatch) return { appName: '', windowTitle: '' };
const winId = activeMatch[1];
const details = execFileSync('xprop', ['-id', winId, '_NET_WM_NAME', 'WM_NAME', 'WM_CLASS'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 1200,
});
const titleMatch = details.match(/(?:_NET_WM_NAME\(UTF8_STRING\)|WM_NAME\(STRING\)|WM_NAME\(UTF8_STRING\)) = "([^"]*)"/);
const classMatch = details.match(/WM_CLASS\(STRING\) = "([^"]*)"(?:, "([^"]*)")?/);
return {
appName: classMatch ? (classMatch[2] || classMatch[1] || '') : '',
windowTitle: titleMatch ? titleMatch[1] : '',
};
}
async buildCaptureTitle({ mode, frame, clickPos, clickMeta = null }) {
const ctx = await this.buildCaptureContext({ mode, frame, clickPos, clickMeta });
return ctx.title;
}
async buildCaptureContext({ mode, frame, clickPos, clickMeta = null }) {
const keyContext = clickMeta?.keyContext || {};
const recentTyped = keyContext.recentTyped || '';
const recentShortcut = keyContext.recentShortcut || '';
// Use window context pre-captured by the click watcher when available.
// This avoids a costly PowerShell cold-start (13 s) on every capture.
const fastContext = clickMeta?.windowContext || null;
const [metadata, ocr] = await Promise.all([
fastContext
? Promise.resolve(fastContext)
: this.collectForegroundWindowContext(clickMeta?.osPoint || null),
this.ocrAroundClick(frame, clickPos),
]);
const title = buildCaptureTitle({ mode, metadata, ocrText: ocr.text, recentTyped, recentShortcut });
return {
title,
captureMetadata: {
ocrText: ocr.text || '',
windowTitle: metadata.windowTitle || '',
appName: metadata.appName || '',
elementLabel: metadata.elementLabel || '',
elementRole: metadata.elementRole || '',
elementValue: metadata.elementValue || '',
recentTyped,
recentShortcut,
mode,
},
};
}
aiEnabled() {
return Boolean(this.settings.get('ai.enabled'));
}
aiConfig(override = null) {
const stored = this.settings.get('ai') || {};
const merged = override ? {
...stored,
...override,
ollama: {
...(stored.ollama || {}),
...(override.ollama || {}),
},
} : stored;
return {
...merged,
enabled: override && Object.prototype.hasOwnProperty.call(override, 'enabled')
? Boolean(override.enabled)
: Boolean(stored.enabled),
ollama: {
host: normalizeOllamaHost(merged.ollama?.host || ''),
model: normalizeWhitespace(merged.ollama?.model || ''),
},
};
}
async testAiConnection(override = null) {
const config = this.aiConfig(override);
if (!config.ollama.host) {
return { ok: false, reason: 'Set an Ollama host first.' };
}
const tagsUrl = new URL('/api/tags', `${config.ollama.host.replace(/\/+$/, '')}/`);
const res = await this.fetch(tagsUrl, { method: 'GET' });
if (!res.ok) {
return { ok: false, reason: `Ollama check failed (${res.status})` };
}
const data = await res.json();
const models = Array.isArray(data?.models) ? data.models.map((model) => model.name).filter(Boolean) : [];
const installed = config.ollama.model ? models.includes(config.ollama.model) : false;
const vision = installed ? await this.modelSupportsVision({
host: config.ollama.host,
model: config.ollama.model,
}) : false;
return {
ok: true,
installed,
vision,
models,
host: config.ollama.host,
model: config.ollama.model,
};
}
async modelCapabilities({ host, model }) {
const normalizedHost = normalizeOllamaHost(host);
const normalizedModel = normalizeWhitespace(model);
if (!normalizedHost || !normalizedModel) return [];
const cacheKey = `${normalizedHost}::${normalizedModel}`;
if (this.modelCapabilityCache.has(cacheKey)) {
return this.modelCapabilityCache.get(cacheKey);
}
const url = new URL('/api/show', `${normalizedHost.replace(/\/+$/, '')}/`);
let capabilities = [];
try {
const response = await this.fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: normalizedModel }),
});
if (response.ok) {
const payload = await response.json();
capabilities = Array.isArray(payload?.capabilities)
? payload.capabilities.map((cap) => normalizeWhitespace(cap).toLowerCase()).filter(Boolean)
: [];
}
} catch {
capabilities = [];
}
if (!capabilities.includes('vision') && modelLooksVisionCapable(normalizedModel)) {
capabilities = [...capabilities, 'vision'];
}
this.modelCapabilityCache.set(cacheKey, capabilities);
return capabilities;
}
async modelSupportsVision({ host, model }) {
const capabilities = await this.modelCapabilities({ host, model });
return capabilities.includes('vision');
}
readStepImageBase64(guideId, stepId) {
const imagePath = this.store.stepImagePath(guideId, stepId, 'working') || this.store.stepImagePath(guideId, stepId, 'original');
if (!imagePath || !fs.existsSync(imagePath)) return '';
return fs.readFileSync(imagePath).toString('base64');
}
async callOllamaText({ host, model, prompt, systemPrompt }) {
const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
const response = await this.fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
stream: false,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: prompt },
],
options: { temperature: 0.4 },
}),
});
if (!response.ok) throw new Error(`Ollama request failed (${response.status})`);
const payload = await response.json();
const content = payload?.message?.content;
if (typeof content !== 'string' || !content.trim()) throw new Error('Ollama returned an empty response');
return content.trim();
}
async callOllama({ host, model, prompt, systemPrompt, images = [] }) {
const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
const userMessage = { role: 'user', content: prompt };
if (Array.isArray(images) && images.length) {
userMessage.images = images;
}
const response = await this.fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
stream: false,
format: 'json',
messages: [
{ role: 'system', content: systemPrompt },
userMessage,
],
options: {
temperature: 0.2,
},
}),
});
if (!response.ok) {
throw new Error(`Ollama request failed (${response.status})`);
}
const payload = await response.json();
const content = payload?.message?.content;
if (typeof content !== 'string' || !content.trim()) {
throw new Error('Ollama returned an empty response');
}
return content;
}
async generateStepPatch({
guideId,
stepId,
target = 'all',
blockId = null,
}) {
try {
const config = this.aiConfig();
if (!config.enabled) {
return { ok: false, reason: 'Enable AI in settings first.' };
}
if (!config.ollama.host || !config.ollama.model) {
return { ok: false, reason: 'Configure Ollama host and model in Settings.' };
}
const guide = this.store.getGuide(guideId);
const step = this.store.getStep(guideId, stepId);
if (!guide || !step) {
return { ok: false, reason: 'Guide or step not found.' };
}
const currentBlock = blockId
? [...(step.textBlocks || []), ...(step.codeBlocks || []), ...(step.tableBlocks || [])].find((b) => b.id === blockId) || null
: null;
if (blockId && target === 'block' && !currentBlock) {
return { ok: false, reason: 'Block not found.' };
}
const screenshotBase64 = step.image ? this.readStepImageBase64(guideId, stepId) : '';
const screenshotAttached = Boolean(screenshotBase64)
? await this.modelSupportsVision({
host: config.ollama.host,
model: config.ollama.model,
})
: false;
let captureContext = null;
// Use stored capture metadata when available (best context, from capture time).
// Fall back to re-running OCR on the stored image only when metadata is absent.
if (step.captureMetadata) {
const rawCandidate = buildCaptureTitle({
mode: step.captureMetadata.mode || 'fullscreen',
metadata: {
windowTitle: step.captureMetadata.windowTitle,
appName: step.captureMetadata.appName,
elementLabel: step.captureMetadata.elementLabel,
elementRole: step.captureMetadata.elementRole,
elementValue: step.captureMetadata.elementValue,
},
ocrText: step.captureMetadata.ocrText,
recentTyped: step.captureMetadata.recentTyped,
recentShortcut: step.captureMetadata.recentShortcut,
});
captureContext = {
...step.captureMetadata,
// Don't suggest a generic fallback title — leave it blank so AI generates from context.
titleCandidate: DEFAULT_TITLE_VALUES.has(rawCandidate) ? '' : rawCandidate,
};
} else if (step.image) {
const imagePath = this.store.stepImagePath(guideId, stepId, 'working') || this.store.stepImagePath(guideId, stepId, 'original');
if (imagePath && fs.existsSync(imagePath)) {
const { nativeImage } = require('electron');
const image = nativeImage.createFromPath(imagePath);
if (!image.isEmpty()) {
const clickPoint = this.clickPointFromStep(step, image);
const [metadata, ocr] = await Promise.all([
this.collectForegroundWindowContext(),
this.ocrAroundClick({ image, size: image.getSize(), display: { bounds: { x: 0, y: 0, width: image.getSize().width, height: image.getSize().height } } }, clickPoint),
]);
const rawCandidate2 = buildCaptureTitle({
mode: step.kind === 'image' ? 'fullscreen' : 'window',
metadata,
ocrText: ocr.text,
});
captureContext = {
...metadata,
ocrText: ocr.text,
titleCandidate: DEFAULT_TITLE_VALUES.has(rawCandidate2) ? '' : rawCandidate2,
mode: step.kind === 'image' ? 'fullscreen' : 'content',
};
}
}
}
const { systemPrompt, prompt } = buildAiPrompt({
target,
guide,
step,
captureContext,
block: currentBlock,
screenshotAttached,
});
const raw = await this.callOllama({
host: config.ollama.host,
model: config.ollama.model,
prompt,
systemPrompt,
images: screenshotAttached ? [screenshotBase64] : [],
});
const patch = normalizeAiPatch(raw);
const updated = applyAiPatchToStep(step, patch, { target, blockId });
const saved = this.store.saveStep(guideId, updated);
return { ok: true, step: saved, patch };
} catch (err) {
return { ok: false, reason: err && err.message ? err.message : 'AI generation failed.' };
}
}
async rewriteText({ text, guideTitle = '', stepTitle = '' }) {
try {
const config = this.aiConfig();
if (!config.enabled) return { ok: false, reason: 'Enable AI in settings first.' };
if (!config.ollama.host || !config.ollama.model) {
return { ok: false, reason: 'Configure Ollama host and model in Settings.' };
}
const trimmed = normalizeWhitespace(text);
if (!trimmed) return { ok: false, reason: 'No text to rewrite.' };
const contextHint = [
guideTitle ? `Guide: ${guideTitle}` : '',
stepTitle ? `Step: ${stepTitle}` : '',
].filter(Boolean).join('\n');
const prompt = [
contextHint,
contextHint ? '' : null,
'Rewrite the following text to sound professional and clear as step-by-step documentation.',
'Keep it concise. Do not add extra information. Return only the rewritten text.',
'',
trimmed,
].filter((l) => l !== null).join('\n');
const result = await this.callOllamaText({
host: config.ollama.host,
model: config.ollama.model,
prompt,
systemPrompt: 'You are a documentation editor. Return only the improved text, nothing else.',
});
return { ok: true, text: result };
} catch (err) {
return { ok: false, reason: err?.message || 'Rewrite failed.' };
}
}
clickPointFromStep(step, image = null) {
const marker = (step.annotations || []).find((ann) => ann.type === 'oval' && Number.isFinite(ann.x) && Number.isFinite(ann.y) && Number.isFinite(ann.w) && Number.isFinite(ann.h));
if (!marker) return null;
const size = image ? image.getSize() : step.image?.size || { width: 0, height: 0 };
if (!size.width || !size.height) return null;
return {
x: Math.round((marker.x + marker.w / 2) * size.width),
y: Math.round((marker.y + marker.h / 2) * size.height),
};
}
}
module.exports = { TextIntelService };
+92
View File
@@ -0,0 +1,92 @@
'use strict';
const { spawn } = require('node:child_process');
/**
* Opt a set of OS processes out of Windows Power Throttling (EcoQoS) and raise
* them to high priority, so the capture pipeline keeps running at full CPU
* speed while the laptop is on battery in a power-saving plan.
*
* Why this exists: during a recording StepForge hides its window, so Windows
* treats the frame-capture worker renderer (and the GPU / screen-capture
* utility processes feeding it) as background work and CPU-throttles them on
* DC power. Throttled, the worker can't sample frames fast enough every
* click then finds no fresh pre-click frame and falls back to a slow
* post-click shot, which is what broke recordings on battery only.
*
* The Chromium command-line switches in main.js stop Chromium's own
* backgrounding; this goes one level lower and clears the OS EcoQoS flag via
* SetProcessInformation(ProcessPowerThrottling, EXECUTION_SPEED off), which
* has no Node binding, so we drive it through a short-lived PowerShell.
*
* Best-effort and fire-and-forget: any failure (older Windows without the API,
* PowerShell blocked by policy, a process that already exited) is swallowed
* the Chromium switches still apply and capture degrades gracefully.
*
* No-op on every non-Windows platform.
*
* @param {number[]} pids OS process ids to keep responsive.
*/
function keepProcessesResponsive(pids) {
if (process.platform !== 'win32') return;
// Only integers reach the script, so the interpolation below can't inject.
const list = [...new Set((pids || []).filter((p) => Number.isInteger(p) && p > 0))];
if (!list.length) return;
const ps = `
$ErrorActionPreference = 'SilentlyContinue'
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public static class SFPower {
[StructLayout(LayoutKind.Sequential)]
struct PROCESS_POWER_THROTTLING_STATE { public uint Version; public uint ControlMask; public uint StateMask; }
const int ProcessPowerThrottling = 4;
const uint PROCESS_POWER_THROTTLING_CURRENT_VERSION = 1;
const uint PROCESS_POWER_THROTTLING_EXECUTION_SPEED = 0x1;
const uint HIGH_PRIORITY_CLASS = 0x00000080;
const uint PROCESS_SET_INFORMATION = 0x0200;
const uint PROCESS_QUERY_INFORMATION = 0x0400;
[DllImport("kernel32.dll", SetLastError=true)]
static extern IntPtr OpenProcess(uint access, bool inherit, uint pid);
[DllImport("kernel32.dll", SetLastError=true)]
static extern bool SetProcessInformation(IntPtr h, int cls, ref PROCESS_POWER_THROTTLING_STATE info, uint size);
[DllImport("kernel32.dll")]
static extern bool SetPriorityClass(IntPtr h, uint cls);
[DllImport("kernel32.dll")]
static extern bool CloseHandle(IntPtr h);
public static void Apply(uint pid) {
IntPtr h = OpenProcess(PROCESS_SET_INFORMATION | PROCESS_QUERY_INFORMATION, false, pid);
if (h == IntPtr.Zero) return;
try {
PROCESS_POWER_THROTTLING_STATE s = new PROCESS_POWER_THROTTLING_STATE();
s.Version = PROCESS_POWER_THROTTLING_CURRENT_VERSION;
s.ControlMask = PROCESS_POWER_THROTTLING_EXECUTION_SPEED;
s.StateMask = 0; // 0 => throttling off => opt out of EcoQoS
SetProcessInformation(h, ProcessPowerThrottling, ref s, (uint)Marshal.SizeOf(s));
SetPriorityClass(h, HIGH_PRIORITY_CLASS);
} finally { CloseHandle(h); }
}
}
'@
${list.map((p) => `[SFPower]::Apply(${p})`).join('\n')}
`;
try {
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', ps],
{ stdio: 'ignore', windowsHide: true },
);
child.on('error', () => { /* PowerShell missing/blocked — best effort */ });
child.unref();
} catch {
// spawn itself failed; the Chromium switches remain in effect.
}
}
module.exports = { keepProcessesResponsive };
+26
View File
@@ -0,0 +1,26 @@
'use strict';
const { GuideStore } = require('./store');
const { buildRenderAst } = require('./renderast');
const { runExport } = require('../exporters');
/**
* Entry point for the export helper process (forked via
* app/export-runner.js). Runs one export job building the render AST and
* invoking the exporter off the main process so a large guide's PDF/DOCX/
* etc. rendering never blocks the UI.
*/
process.on('message', (job) => {
let payload;
try {
const { dataDir, guideId, format, options, outDir, globals, maxSteps } = job;
const store = new GuideStore(dataDir);
const ast = buildRenderAst(store, guideId, { globals, maxSteps });
const result = runExport(format, ast, outDir, options || {});
payload = { ok: true, result };
} catch (err) {
payload = { ok: false, error: err && err.message ? err.message : String(err) };
}
process.send(payload, () => process.exit(payload.ok ? 0 : 1));
});
+125
View File
@@ -0,0 +1,125 @@
'use strict';
const { decodeEntities } = require('./util');
/**
* Parse sanitized description HTML (see core/sanitize.js) into a flat list
* of blocks with inline formatting runs, for renderers (PDF) that need to
* preserve bold/italic/links/lists rather than flattening to plain text.
*
* Each block: { type, runs, indent, n? }
* type: 'p' | 'h1'..'h4' | 'blockquote' | 'li' | 'oli' | 'hr'
* runs: [{ text, bold, italic, code, href }] (absent for 'hr')
* indent: list/quote nesting depth (0 = top level)
* n: list item number, only for 'oli'
*/
const TAG_RE = /<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
const HREF_RE = /href\s*=\s*"([^"]*)"|href\s*=\s*'([^']*)'/i;
function htmlToBlocks(html) {
const blocks = [];
let current = null;
const styleStack = [{}];
const context = []; // nesting of <ul>/<ol>/<blockquote>
const indentLevel = () => context.length;
const currentStyle = () => styleStack[styleStack.length - 1];
const flushBlock = () => {
if (!current) return;
if (current.type === 'hr' || current.runs.some((r) => r.text.trim() !== '')) blocks.push(current);
current = null;
};
const startBlock = (type, extra = {}) => {
flushBlock();
current = { type, runs: [], indent: indentLevel(), ...extra };
};
const pushText = (text) => {
if (!text) return;
if (!current) current = { type: 'p', runs: [], indent: indentLevel() };
current.runs.push({ text, ...currentStyle() });
};
let last = 0;
let m;
while ((m = TAG_RE.exec(html)) !== null) {
if (m.index > last) pushText(decodeEntities(html.slice(last, m.index)));
last = TAG_RE.lastIndex;
const closing = Boolean(m[1]);
const tag = m[2].toLowerCase();
const rawAttrs = m[3];
switch (tag) {
case 'p':
case 'div':
if (closing) flushBlock();
else startBlock('p');
break;
case 'h1':
case 'h2':
case 'h3':
case 'h4':
if (closing) flushBlock();
else startBlock(tag);
break;
case 'blockquote':
if (closing) { flushBlock(); context.pop(); }
else { context.push({ kind: 'blockquote' }); startBlock('blockquote'); }
break;
case 'ul':
case 'ol':
if (closing) context.pop();
else context.push({ kind: tag, counter: 0 });
break;
case 'li':
if (closing) flushBlock();
else {
const ctx = context[context.length - 1];
const depth = Math.max(0, indentLevel() - 1);
if (ctx && ctx.kind === 'ol') { ctx.counter += 1; startBlock('oli', { n: ctx.counter, indent: depth }); }
else startBlock('li', { indent: depth });
}
break;
case 'br':
flushBlock();
break;
case 'hr':
flushBlock();
blocks.push({ type: 'hr' });
break;
case 'b':
case 'strong':
if (closing) styleStack.pop();
else styleStack.push({ ...currentStyle(), bold: true });
break;
case 'i':
case 'em':
if (closing) styleStack.pop();
else styleStack.push({ ...currentStyle(), italic: true });
break;
case 'code':
case 'pre':
if (closing) styleStack.pop();
else styleStack.push({ ...currentStyle(), code: true });
break;
case 'a':
if (closing) styleStack.pop();
else {
const hrefM = HREF_RE.exec(rawAttrs);
styleStack.push({ ...currentStyle(), href: hrefM ? (hrefM[1] ?? hrefM[2]) : undefined });
}
break;
default:
// Unrecognized/transparent allowed tags (table*, span, u, s, sub, sup):
// no block or style change.
break;
}
}
pushText(decodeEntities(html.slice(last)));
flushBlock();
return blocks;
}
module.exports = { htmlToBlocks };
+89 -4
View File
@@ -9,19 +9,41 @@ const zlib = require('node:zlib');
* points; converted to PDF's bottom-left space internally. * points; converted to PDF's bottom-left space internally.
*/ */
const FONTS = { F1: 'Helvetica', F2: 'Helvetica-Bold', F3: 'Courier' }; const FONTS = { F1: 'Helvetica', F2: 'Helvetica-Bold', F3: 'Courier', F4: 'Helvetica-Oblique', F5: 'Helvetica-BoldOblique' };
// Approximate average glyph width factors (per 1pt font size) for wrapping. // Approximate average glyph width factors (per 1pt font size) for wrapping.
const FONT_WIDTH_FACTOR = { F1: 0.51, F2: 0.55, F3: 0.6 }; const FONT_WIDTH_FACTOR = { F1: 0.51, F2: 0.55, F3: 0.6, F4: 0.51, F5: 0.55 };
function esc(text) { function esc(text) {
return String(text).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); return String(text).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
} }
// "Smart typography" characters commonly produced by rich-text editors and
// pasted content (e.g. from Word/Google Docs) that have a printable glyph in
// WinAnsiEncoding (cp1252) outside the Latin-1 range; map to that byte
// instead of falling back to "?".
const WINANSI_EXTRA = {
0x20ac: 0x80, // €
0x2026: 0x85, // … ellipsis
0x2030: 0x89, // ‰
0x2039: 0x8b, //
0x203a: 0x9b, //
0x2018: 0x91, // ' left single quote
0x2019: 0x92, // ' right single quote
0x201c: 0x93, // " left double quote
0x201d: 0x94, // " right double quote
0x2022: 0x95, // • bullet
0x2013: 0x96, // en dash
0x2014: 0x97, // — em dash
0x2122: 0x99, // ™
};
function toLatin1(text) { function toLatin1(text) {
let out = ''; let out = '';
for (const ch of String(text)) { for (const ch of String(text)) {
const code = ch.codePointAt(0); const code = ch.codePointAt(0);
out += code <= 0xff ? ch : '?'; if (code <= 0xff) out += ch;
else if (WINANSI_EXTRA[code] !== undefined) out += String.fromCharCode(WINANSI_EXTRA[code]);
else out += '?';
} }
return out; return out;
} }
@@ -38,6 +60,7 @@ class PdfBuilder {
this.images = []; // { name, width, height, data (deflated RGB), smask? } this.images = []; // { name, width, height, data (deflated RGB), smask? }
this.imageCache = new Map(); this.imageCache = new Map();
this.bookmarks = []; // { title, pageIndex, y } this.bookmarks = []; // { title, pageIndex, y }
this.links = []; // { pageIndex, x, yTop, w, h, target }
} }
addPage() { addPage() {
@@ -73,11 +96,42 @@ class PdfBuilder {
text(str, x, yTop, { size = 11, font = 'F1', color = [0, 0, 0] } = {}) { text(str, x, yTop, { size = 11, font = 'F1', color = [0, 0, 0] } = {}) {
const y = this.pageHeight - yTop - size; const y = this.pageHeight - yTop - size;
// Tabs have no glyph in WinAnsiEncoding and render as "?"; expand to spaces.
const clean = String(str).replace(/\t/g, ' ');
this.currentPage.ops.push( this.currentPage.ops.push(
`BT /${font} ${size} Tf ${col(color)} rg 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm (${esc(toLatin1(str))}) Tj ET` `BT /${font} ${size} Tf ${col(color)} rg 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm (${esc(toLatin1(clean))}) Tj ET`
); );
} }
/**
* Render a line made of mixed-font/color segments (e.g. bold/italic runs)
* as one continuous text object: consecutive Tj operators without an
* intervening Tm advance the cursor by each font's real glyph widths, so
* spacing matches the viewer's metrics exactly (unlike our approximate
* textWidth(), which is only used for word-wrap decisions).
*/
textRun(parts, x, yTop, size) {
const y = this.pageHeight - yTop - size;
const ops = [`BT 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm`];
let curFont = null;
let curColor = null;
for (const part of parts) {
if (!part.text) continue;
if (part.font !== curFont) {
ops.push(`/${part.font} ${size} Tf`);
curFont = part.font;
}
if (!curColor || part.color[0] !== curColor[0] || part.color[1] !== curColor[1] || part.color[2] !== curColor[2]) {
ops.push(`${col(part.color)} rg`);
curColor = part.color;
}
const clean = String(part.text).replace(/\t/g, ' ');
ops.push(`(${esc(toLatin1(clean))}) Tj`);
}
ops.push('ET');
this.currentPage.ops.push(ops.join(' '));
}
rect(x, yTop, w, h, { fill = null, stroke = null, lineWidth = 1 } = {}) { rect(x, yTop, w, h, { fill = null, stroke = null, lineWidth = 1 } = {}) {
const y = this.pageHeight - yTop - h; const y = this.pageHeight - yTop - h;
const ops = []; const ops = [];
@@ -120,6 +174,16 @@ class PdfBuilder {
this.bookmarks.push({ title: toLatin1(title), pageIndex }); this.bookmarks.push({ title: toLatin1(title), pageIndex });
} }
/**
* Add a clickable region that jumps to another page (e.g. a Contents
* entry linking to a step). `target` is either a page index, or an
* object whose `pageIndex` is filled in later once the destination
* page is known and read when `build()` runs.
*/
linkRect(x, yTop, w, h, target, pageIndex = this.pages.length - 1) {
this.links.push({ pageIndex, x, yTop, w, h, target });
}
build() { build() {
if (!this.pages.length) this.addPage(); if (!this.pages.length) this.addPage();
const objects = []; // 1-based; objects[i] = body string|Buffer after header const objects = []; // 1-based; objects[i] = body string|Buffer after header
@@ -159,6 +223,27 @@ class PdfBuilder {
)); ));
} }
// Link annotations (e.g. clickable Contents entries that jump to a
// step's page). Targets resolved late, after every step has claimed a
// page, so `target` may be an object whose `pageIndex` was only just set.
const pageAnnots = new Map(); // page index -> [annotation object id, ...]
for (const link of this.links) {
const targetIndex = typeof link.target === 'number' ? link.target : link.target?.pageIndex;
if (targetIndex == null || !pageIds[targetIndex]) continue;
const y1 = this.pageHeight - link.yTop - link.h;
const y2 = this.pageHeight - link.yTop;
const annotId = addObj(
`<< /Type /Annot /Subtype /Link /Rect [${link.x.toFixed(2)} ${y1.toFixed(2)} ${(link.x + link.w).toFixed(2)} ${y2.toFixed(2)}] ` +
`/Border [0 0 0] /Dest [${pageIds[targetIndex]} 0 R /Fit] >>`
);
if (!pageAnnots.has(link.pageIndex)) pageAnnots.set(link.pageIndex, []);
pageAnnots.get(link.pageIndex).push(annotId);
}
for (const [pageIndex, annotIds] of pageAnnots) {
const id = pageIds[pageIndex];
objects[id - 1] = objects[id - 1].replace(/ >>$/, ` /Annots [${annotIds.map((a) => `${a} 0 R`).join(' ')}] >>`);
}
let outlinesRef = ''; let outlinesRef = '';
if (this.bookmarks.length) { if (this.bookmarks.length) {
const outlineRootId = objects.length + 1; const outlineRootId = objects.length + 1;
+10 -4
View File
@@ -392,13 +392,19 @@ function renderAnnotations(baseImg, annotations = []) {
return img; return img;
} }
/** Apply focused view (zoom/pan crop, then scale back to original size). */ /**
* Apply focused view (zoom/pan crop, then scale back to original size).
* panX/panY are 0..1 fractions of the available pan range, not absolute
* image positions: 0 puts the crop at the left/bottom edge, 1 at the
* right/top edge, 0.5 centers it. Y is inverted so panY increases upward.
*/
function applyFocusedView(img, fv) { function applyFocusedView(img, fv) {
if (!fv || !fv.enabled || !(fv.zoom > 1)) return img; if (!fv || !fv.enabled || !(fv.zoom > 1)) return img;
const vw = img.width / fv.zoom, vh = img.height / fv.zoom; const vw = img.width / fv.zoom, vh = img.height / fv.zoom;
const cx = Math.min(Math.max(fv.panX * img.width, vw / 2), img.width - vw / 2); const panX = fv.panX ?? 0.5, panY = fv.panY ?? 0.5;
const cy = Math.min(Math.max(fv.panY * img.height, vh / 2), img.height - vh / 2); const cropX = panX * (img.width - vw);
const region = crop(img, cx - vw / 2, cy - vh / 2, vw, vh); const cropY = (1 - panY) * (img.height - vh);
const region = crop(img, cropX, cropY, vw, vh);
return resize(region, img.width, img.height); return resize(region, img.width, img.height);
} }
+18 -9
View File
@@ -3,7 +3,7 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { sanitizeHtml } = require('./sanitize'); const { sanitizeHtml } = require('./sanitize');
const { htmlToText, deepClone } = require('./util'); const { htmlToText, linkifyMarkdownLinks, deepClone } = require('./util');
const { systemPlaceholders, resolveScopes, expandPlaceholders } = require('./placeholders'); const { systemPlaceholders, resolveScopes, expandPlaceholders } = require('./placeholders');
const { decodePng } = require('./png'); const { decodePng } = require('./png');
const { renderAnnotations, applyFocusedView } = require('./raster'); const { renderAnnotations, applyFocusedView } = require('./raster');
@@ -32,6 +32,10 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
system: systemPlaceholders(guide, { now, stepCount: includedIds.length }), system: systemPlaceholders(guide, { now, stepCount: includedIds.length }),
}); });
const expand = (text) => expandPlaceholders(text, values); const expand = (text) => expandPlaceholders(text, values);
// Description fields additionally turn literal "[text](url)" markdown
// link syntax (as inserted by the editor's Link button) into real <a>
// tags before sanitizing, so exporters render an actual link.
const expandDesc = (html) => linkifyMarkdownLinks(expand(html || ''));
const steps = []; const steps = [];
const topCounter = { n: 0 }; const topCounter = { n: 0 };
@@ -66,15 +70,15 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
skipped: step.skipped, skipped: step.skipped,
forceNewPage: Boolean(step.forceNewPage), forceNewPage: Boolean(step.forceNewPage),
title: expand(step.title || ''), title: expand(step.title || ''),
descriptionHtml: sanitizeHtml(expand(step.descriptionHtml || '')), descriptionHtml: sanitizeHtml(expandDesc(step.descriptionHtml)),
descriptionText: htmlToText(expand(step.descriptionHtml || '')), descriptionText: htmlToText(expandDesc(step.descriptionHtml)),
focusedView: step.focusedView, focusedView: step.focusedView,
annotations: (step.annotations || []).map((a) => ({ ...a, text: expand(a.text || '') })), annotations: (step.annotations || []).map((a) => ({ ...a, text: expand(a.text || '') })),
textBlocks: (step.textBlocks || []).map((tb) => ({ textBlocks: (step.textBlocks || []).map((tb) => ({
...tb, ...tb,
title: expand(tb.title || ''), title: expand(tb.title || ''),
descriptionHtml: sanitizeHtml(expand(tb.descriptionHtml || '')), descriptionHtml: sanitizeHtml(expandDesc(tb.descriptionHtml)),
descriptionText: htmlToText(expand(tb.descriptionHtml || '')), descriptionText: htmlToText(expandDesc(tb.descriptionHtml)),
})), })),
codeBlocks: (step.codeBlocks || []).map((cb) => ({ ...cb, code: blockText(cb) })), codeBlocks: (step.codeBlocks || []).map((cb) => ({ ...cb, code: blockText(cb) })),
tableBlocks: (step.tableBlocks || []).map((tb) => ({ tableBlocks: (step.tableBlocks || []).map((tb) => ({
@@ -86,8 +90,8 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
return { return {
...block, ...block,
title: expand(block.title || ''), title: expand(block.title || ''),
descriptionHtml: sanitizeHtml(expand(block.descriptionHtml || '')), descriptionHtml: sanitizeHtml(expandDesc(block.descriptionHtml)),
descriptionText: htmlToText(expand(block.descriptionHtml || '')), descriptionText: htmlToText(expandDesc(block.descriptionHtml)),
}; };
} }
if (block.kind === 'code') return { ...block }; if (block.kind === 'code') return { ...block };
@@ -116,11 +120,16 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
guide: { guide: {
id: guide.guideId, id: guide.guideId,
title: expand(guide.title), title: expand(guide.title),
descriptionHtml: sanitizeHtml(expand(guide.descriptionHtml || '')), descriptionHtml: sanitizeHtml(expandDesc(guide.descriptionHtml)),
descriptionText: htmlToText(expand(guide.descriptionHtml || '')), descriptionText: htmlToText(expandDesc(guide.descriptionHtml)),
createdAt: guide.createdAt, createdAt: guide.createdAt,
updatedAt: guide.updatedAt, updatedAt: guide.updatedAt,
flags: guide.flags, flags: guide.flags,
metadata: {
author: expand(guide.metadata?.author || ''),
coAuthors: expand(guide.metadata?.coAuthors || ''),
organization: expand(guide.metadata?.organization || ''),
},
}, },
steps: limited, steps: limited,
}; };
+10
View File
@@ -34,6 +34,12 @@ function createGuide(fields = {}) {
title: fields.title || 'Untitled guide', title: fields.title || 'Untitled guide',
descriptionHtml: sanitizeHtml(fields.descriptionHtml || ''), descriptionHtml: sanitizeHtml(fields.descriptionHtml || ''),
placeholders: { ...(fields.placeholders || {}) }, placeholders: { ...(fields.placeholders || {}) },
metadata: {
author: '',
coAuthors: '',
organization: '',
...(fields.metadata || {}),
},
flags: { flags: {
focusedViewDefault: false, focusedViewDefault: false,
hideSkippedStepsInExports: true, hideSkippedStepsInExports: true,
@@ -80,6 +86,9 @@ function createStep(fields = {}) {
codeBlocks: (fields.codeBlocks || []).map((cb) => normalizeCodeBlock(cb, takeOrder(cb))), codeBlocks: (fields.codeBlocks || []).map((cb) => normalizeCodeBlock(cb, takeOrder(cb))),
tableBlocks: (fields.tableBlocks || []).map((tb) => normalizeTableBlock(tb, takeOrder(tb))), tableBlocks: (fields.tableBlocks || []).map((tb) => normalizeTableBlock(tb, takeOrder(tb))),
links: fields.links || [], // { id, label, targetStepId } links: fields.links || [], // { id, label, targetStepId }
captureMetadata: (fields.captureMetadata && typeof fields.captureMetadata === 'object' && !Array.isArray(fields.captureMetadata))
? { ...fields.captureMetadata }
: null,
}; };
} }
@@ -149,6 +158,7 @@ function validateGuide(guide) {
if (!Array.isArray(guide.stepsOrder)) errors.push('stepsOrder must be an array'); if (!Array.isArray(guide.stepsOrder)) errors.push('stepsOrder must be an array');
else if (new Set(guide.stepsOrder).size !== guide.stepsOrder.length) errors.push('stepsOrder has duplicates'); else if (new Set(guide.stepsOrder).size !== guide.stepsOrder.length) errors.push('stepsOrder has duplicates');
if (guide.placeholders && typeof guide.placeholders !== 'object') errors.push('placeholders must be an object'); if (guide.placeholders && typeof guide.placeholders !== 'object') errors.push('placeholders must be an object');
if (guide.metadata && typeof guide.metadata !== 'object') errors.push('metadata must be an object');
if (errors.length) throw new Error(`invalid guide: ${errors.join('; ')}`); if (errors.length) throw new Error(`invalid guide: ${errors.join('; ')}`);
return guide; return guide;
} }
+7
View File
@@ -47,6 +47,13 @@ const DEFAULT_SETTINGS = {
focusedViewDefaultForNewSteps: false, focusedViewDefaultForNewSteps: false,
autoTitleTemplate: '[[Mode]] capture [[Time]]', autoTitleTemplate: '[[Mode]] capture [[Time]]',
}, },
ai: {
enabled: false,
ollama: {
host: 'http://127.0.0.1:11434',
model: 'llama3.2:1b',
},
},
exports: { exports: {
previewStepCount: 3, previewStepCount: 3,
openFolderAfterExport: true, openFolderAfterExport: true,
+21
View File
@@ -244,6 +244,27 @@ class GuideStore {
this.saveGuide(guide); this.saveGuide(guide);
} }
/**
* Recreate a previously-deleted step, preserving its stepId, and splice it
* back into the guide's order. Used to undo step deletion.
*/
restoreStep(guideId, step, images, position) {
const guide = this.getGuide(guideId);
const restored = normalizeStep(deepClone(step));
validateStep(restored);
const dir = this.stepDir(guideId, restored.stepId);
fs.mkdirSync(dir, { recursive: true });
if (restored.image && images) {
if (images.original) atomicWriteFileSync(path.join(dir, restored.image.originalPath), images.original);
if (images.working) atomicWriteFileSync(path.join(dir, restored.image.workingPath), images.working);
}
writeJsonSync(path.join(dir, 'step.json'), restored);
const at = Number.isInteger(position) ? Math.min(position, guide.stepsOrder.length) : guide.stepsOrder.length;
guide.stepsOrder.splice(at, 0, restored.stepId);
this.saveGuide(guide);
return restored;
}
reorderSteps(guideId, newOrder) { reorderSteps(guideId, newOrder) {
const guide = this.getGuide(guideId); const guide = this.getGuide(guideId);
const current = new Set(guide.stepsOrder); const current = new Set(guide.stepsOrder);
+16 -2
View File
@@ -11,7 +11,21 @@ const { writeJsonSync, readJsonSync, atomicWriteFileSync, nowIso } = require('./
* defaults, shareable as .sfglt zip files. * defaults, shareable as .sfglt zip files.
*/ */
const FORMATS = ['json', 'markdown', 'html-simple', 'html-rich', 'confluence', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx']; const FORMATS = ['json', 'markdown', 'wikijs', 'html-simple', 'html-rich', 'confluence', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
const FORMAT_LABELS = {
json: 'JSON',
markdown: 'Markdown',
wikijs: 'Wiki.js',
'html-simple': 'HTML (simple)',
'html-rich': 'HTML (rich)',
confluence: 'Confluence',
pdf: 'PDF',
gif: 'GIF',
'image-bundle': 'Image bundle',
docx: 'DOCX',
pptx: 'PPTX',
};
class TemplateManager { class TemplateManager {
constructor(templatesDir) { constructor(templatesDir) {
@@ -96,4 +110,4 @@ class TemplateManager {
} }
} }
module.exports = { TemplateManager, FORMATS }; module.exports = { TemplateManager, FORMATS, FORMAT_LABELS };
+857
View File
@@ -0,0 +1,857 @@
'use strict';
const { deepClone, htmlToText, escapeHtml } = require('./util');
const { sanitizeHtml } = require('./sanitize');
const {
TEXTBLOCK_LEVELS,
TEXTBLOCK_POSITIONS,
normalizeTextBlock,
normalizeCodeBlock,
normalizeTableBlock,
} = require('./schema');
const DEFAULT_CAPTURE_TITLES = {
fullscreen: 'Screen capture',
window: 'Window capture',
region: 'Region capture',
};
const AI_LEVEL_ALIASES = new Map([
['note', 'info'],
['info', 'info'],
['tip', 'success'],
['success', 'success'],
['warning', 'warn'],
['warn', 'warn'],
['important', 'error'],
['error', 'error'],
]);
const GENERIC_OCR_PHRASES = new Set([
'button',
'click',
'double click',
'menu',
'item',
'field',
'text field',
'search',
'submit',
'cancel',
'ok',
'open',
'select',
'enter',
'type',
]);
// Generic OS/browser chrome titles that tell us nothing about what the user did.
const GENERIC_WINDOW_TITLES = new Set([
'new tab', 'new window', 'new incognito window', 'new incognito tab',
'new document', 'untitled', 'blank page', 'home page', 'homepage',
'start page', 'speed dial', 'loading', 'loading…', 'loading...',
]);
const BROWSER_NAME_PHRASES = new Set([
'google chrome',
'chrome',
'chromium',
'microsoft edge',
'edge',
'brave',
'firefox',
'safari',
'opera',
'vivaldi',
]);
// Known search engine page title suffixes (what appears after the query in the window title).
const SEARCH_ENGINE_PAGE_NAMES = new Set([
'google search',
'google',
'bing',
'duckduckgo',
'yahoo search',
'yahoo',
'startpage',
'ecosia',
'brave search',
]);
// Common keyboard shortcuts → short action descriptions used as step titles.
const SHORTCUT_TITLES = {
'Ctrl+T': 'Open new tab',
'Ctrl+N': 'Open new window',
'Ctrl+W': 'Close tab',
'Ctrl+Shift+T': 'Reopen closed tab',
'Ctrl+Shift+N': 'Open incognito window',
'Ctrl+S': 'Save',
'Ctrl+Shift+S': 'Save as',
'Ctrl+Z': 'Undo',
'Ctrl+Y': 'Redo',
'Ctrl+Shift+Z': 'Redo',
'Ctrl+C': 'Copy selection',
'Ctrl+V': 'Paste',
'Ctrl+X': 'Cut selection',
'Ctrl+A': 'Select all',
'Ctrl+F': 'Open Find',
'Ctrl+H': 'Open Find and Replace',
'Ctrl+R': 'Reload page',
'Ctrl+Shift+R': 'Hard reload page',
'Ctrl+L': 'Focus address bar',
'Ctrl+D': 'Bookmark page',
'Ctrl+Tab': 'Switch to next tab',
'Ctrl+Shift+Tab': 'Switch to previous tab',
'Ctrl+Plus': 'Zoom in',
'Ctrl+Minus': 'Zoom out',
'Ctrl+0': 'Reset zoom',
'Ctrl+P': 'Print',
'Ctrl+O': 'Open file',
'Ctrl+E': 'Focus search bar',
'Ctrl+K': 'Focus search bar',
'Ctrl+G': 'Go to line',
'Ctrl+B': 'Toggle sidebar',
'Ctrl+Shift+P': 'Open command palette',
'Ctrl+Shift+E': 'Show file explorer',
'Ctrl+Shift+G': 'Show source control',
'Ctrl+Shift+D': 'Show debug panel',
'Ctrl+Shift+X': 'Show extensions',
'Alt+F4': 'Close window',
'Alt+Left': 'Go back',
'Alt+Right': 'Go forward',
'Alt+Tab': 'Switch application',
'F2': 'Rename',
'F3': 'Find next',
'F4': 'Open address bar',
'F5': 'Reload page',
'F11': 'Toggle fullscreen',
'F12': 'Open developer tools',
};
// Process name → human-readable display name (used to append "in Chrome" etc. to titles).
const APP_DISPLAY_NAMES = {
chrome: 'Chrome',
msedge: 'Edge',
firefox: 'Firefox',
safari: 'Safari',
opera: 'Opera',
brave: 'Brave',
vivaldi: 'Vivaldi',
code: 'VS Code',
cursor: 'Cursor',
'sublime_text': 'Sublime Text',
atom: 'Atom',
notepad: 'Notepad',
'notepad++': 'Notepad++',
winword: 'Word',
excel: 'Excel',
powerpnt: 'PowerPoint',
outlook: 'Outlook',
teams: 'Teams',
slack: 'Slack',
discord: 'Discord',
zoom: 'Zoom',
figma: 'Figma',
postman: 'Postman',
insomnia: 'Insomnia',
notion: 'Notion',
obsidian: 'Obsidian',
spotify: 'Spotify',
terminal: 'Terminal',
cmd: 'Command Prompt',
powershell: 'PowerShell',
windowsterminal: 'Windows Terminal',
wt: 'Windows Terminal',
iterm2: 'iTerm',
wezterm: 'WezTerm',
alacritty: 'Alacritty',
kitty: 'Kitty',
'gnome-terminal': 'Terminal',
konsole: 'Konsole',
xterm: 'Terminal',
xfce4terminal: 'Terminal',
bash: 'Terminal',
zsh: 'Terminal',
fish: 'Terminal',
finder: 'Finder',
explorer: 'File Explorer',
'files-uwp': 'File Explorer',
steam: 'Steam',
'steamwebhelper': 'Steam',
};
function cleanAppName(rawName) {
if (!rawName) return '';
const key = normalizeWhitespace(rawName).toLowerCase().replace(/\.exe$/i, '');
return APP_DISPLAY_NAMES[key] || sentenceCase(rawName.replace(/\.exe$/i, ''));
}
function qualifyTitleWithApp(title, appName) {
const app = cleanAppName(appName);
if (!app) return title;
if (new RegExp(`\\b${app.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(title)) return title;
return `${title} in ${app}`;
}
const ACTION_PREFIXES = [
'click',
'select',
'open',
'choose',
'enter',
'type',
'search',
'switch to',
'go to',
'navigate to',
'toggle',
'turn on',
'turn off',
'enable',
'disable',
'pick',
'focus',
'launch',
'activate',
];
function normalizeWhitespace(text) {
return String(text == null ? '' : text)
.replace(/\s+/g, ' ')
.trim();
}
function titleCaseWord(word) {
if (!word) return word;
if (/^[A-Z0-9]{2,}$/.test(word)) return word;
if (/^\d+$/.test(word)) return word;
return word[0].toUpperCase() + word.slice(1).toLowerCase();
}
function displayText(text) {
const clean = normalizeWhitespace(text)
.replace(/^[\s"'`([{<]+|[\s"'`)}\]>.,;:!?]+$/g, '')
.trim();
if (!clean) return '';
if (clean === clean.toUpperCase()) {
return clean.split(/\s+/).map(titleCaseWord).join(' ');
}
return clean.replace(/\s+/g, ' ');
}
function sentenceCase(text) {
const clean = displayText(text);
if (!clean) return '';
return clean.charAt(0).toUpperCase() + clean.slice(1);
}
function isPathOrUrlLike(text) {
return /^(?:https?:\/\/|file:\/\/|about:blank|chrome:\/\/|edge:\/\/|moz-extension:\/\/|view-source:|localhost(?:[:/]|$)|www\.)/i.test(text) ||
/[A-Za-z]:\\/.test(text) ||
/\/(?:[^/\s]+\/){2,}/.test(text) ||
/\\/.test(text);
}
function isBrowserNoise(text) {
const clean = normalizeWhitespace(text).toLowerCase();
if (!clean) return true;
if (BROWSER_NAME_PHRASES.has(clean)) return true;
if (isPathOrUrlLike(clean)) return true;
let foundBrowserName = false;
for (const name of BROWSER_NAME_PHRASES) {
if (clean.includes(name)) {
foundBrowserName = true;
break;
}
}
return foundBrowserName && /[\s|•·*]{2,}|[-–—]|\/|\\/.test(clean);
}
function isUsefulTitleCandidate(text, { source = 'ocr' } = {}) {
const clean = displayText(text);
if (!clean) return false;
const lower = clean.toLowerCase();
if (GENERIC_OCR_PHRASES.has(lower)) return false;
if (BROWSER_NAME_PHRASES.has(lower)) return false;
if (isPathOrUrlLike(clean)) return false;
if ((source === 'window' || source === 'app') && isBrowserNoise(clean)) return false;
if (source === 'window' && GENERIC_WINDOW_TITLES.has(lower)) return false;
if (/^[\p{P}\p{S}0-9]+$/u.test(clean)) return false;
return true;
}
function splitTitleFragments(text) {
const clean = normalizeWhitespace(text);
if (!clean) return [];
return clean
.split(/\s*(?:\*\*+|[|•·]+|::|\/+|\\+|\s[-–—]\s|\s{2,})\s*/g)
.map((part) => displayText(part))
.filter(Boolean);
}
function candidateWords(text) {
const clean = normalizeWhitespace(text);
if (!clean) return [];
// Exclude standalone punctuation tokens (e.g. "|" in "Oracle | Cloud...") from word count.
return clean.split(/\s+/).filter((w) => /[a-zA-Z0-9]/.test(w));
}
// Remove trailing "- Google Chrome", "| Firefox", etc. from a window title.
// When appName is supplied, also strips the specific app's display name suffix:
// "Document1 - Word" → "Document1" when appName is "winword".
function stripBrowserNameSuffix(text, appName) {
let clean = normalizeWhitespace(text);
// Always strip known browser names first.
for (const name of BROWSER_NAME_PHRASES) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
clean = clean.replace(new RegExp(`\\s*[-–—·|•]\\s*${escaped}\\s*$`, 'i'), '').trim();
}
// Also strip the specific app's display name when provided.
if (appName) {
const display = cleanAppName(appName);
const raw = normalizeWhitespace(appName).replace(/\.exe$/i, '');
for (const name of [display, raw].filter(Boolean)) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
clean = clean.replace(new RegExp(`\\s*[-–—·|•]\\s*${escaped}\\s*$`, 'i'), '').trim();
}
}
return clean;
}
// Detect "[query] - Google Search" or "[query] - Bing" patterns in a (already-stripped) page title.
// Returns the query word(s) if found, otherwise ''.
function extractSearchQuery(pageTitle) {
const frags = splitTitleFragments(pageTitle);
if (frags.length < 2) return '';
const last = frags[frags.length - 1].toLowerCase();
if (SEARCH_ENGINE_PAGE_NAMES.has(last)) {
const query = frags[0];
if (query && isUsefulTitleCandidate(query, { source: 'ocr' })) return query;
}
return '';
}
function scoreCandidate(text, { source = 'ocr' } = {}) {
const clean = displayText(text);
if (!clean) return -Infinity;
const words = candidateWords(clean);
if (!words.length) return -Infinity;
let score = 0;
score += source === 'ocr' ? 140 : source === 'element' ? 95 : source === 'window' ? 35 : source === 'app' ? 25 : 90;
score += Math.min(words.length, 5) * 10;
score -= Math.max(0, words.length - 5) * 11;
score -= Math.max(0, clean.length - 42) * 0.8;
if (GENERIC_OCR_PHRASES.has(clean.toLowerCase())) score -= 50;
if (BROWSER_NAME_PHRASES.has(clean.toLowerCase())) score -= 80;
if (isBrowserNoise(clean)) score -= 60;
if (clean.length <= 24) score += 10;
if (/^(click|select|open|choose|enter|type|search|switch to|go to|navigate to|toggle|turn on|turn off|enable|disable|pick|focus|launch|activate)\b/i.test(clean)) score += 12;
if (/^[\p{P}\p{S}0-9]+$/u.test(clean)) score -= 100;
return score;
}
function pickBestOcrPhrase(ocrText) {
const text = normalizeWhitespace(ocrText);
if (!text) return '';
let best = '';
let bestScore = -Infinity;
for (const rawLine of text.split(/\n+/)) {
const line = normalizeWhitespace(rawLine);
if (!line) continue;
// For short lines (link text, button labels) try the FULL line first before splitting.
// This preserves "Oracle | Cloud Applications and Cloud Platform" instead of splitting on |.
// Full-line bonus (+35) nudges it ahead of its own fragments.
const candidates = line.length <= 80
? [[line, 35], ...splitTitleFragments(line).map((f) => [f, 0])]
: splitTitleFragments(line).map((f) => [f, 0]);
for (const [part, bonus] of candidates) {
if (!isUsefulTitleCandidate(part, { source: 'ocr' })) continue;
const score = scoreCandidate(part, { source: 'ocr' }) + bonus;
if (score > bestScore) {
best = part;
bestScore = score;
}
}
}
return best;
}
function isShortUiLabel(text) {
const words = candidateWords(text);
return words.length > 0 && words.length <= 2 && text.length <= 24;
}
function isDirectiveTitle(text) {
const clean = displayText(text);
if (!clean) return false;
const lower = clean.toLowerCase();
return ACTION_PREFIXES.some((prefix) => lower.startsWith(prefix));
}
function verbForElementRole(role) {
const clean = normalizeWhitespace(role).toLowerCase();
if (!clean) return null;
if (/(tab|menu item|menuitem|option|list item|tree item|radio button|dropdown list|combo box option|hyperlink|link)/.test(clean)) {
return 'Select';
}
if (/(search box|searchbox|search field|search bar|search input)/.test(clean)) {
return 'Search for';
}
if (/(button|check box|checkbox|toggle button|switch|item|command)/.test(clean)) {
return 'Click';
}
if (/(text field|edit|combo box|textbox|text box|input|field)/.test(clean)) {
return 'Click';
}
return null;
}
function formatCaptureTitle(text, { source = 'ocr', metadata = {} } = {}) {
const clean = displayText(text);
if (!clean) return '';
if (isDirectiveTitle(clean)) {
return sentenceCase(clean);
}
const roleVerb = (source === 'ocr' || source === 'element') ? verbForElementRole(metadata.elementRole) : null;
if (roleVerb) {
return `${roleVerb} ${sentenceCase(clean)}`;
}
if (source === 'window' || source === 'app') {
return `Open ${sentenceCase(clean)}`;
}
if (source === 'ocr' || source === 'element') {
return isShortUiLabel(clean) ? `Click ${sentenceCase(clean)}` : sentenceCase(clean);
}
return sentenceCase(clean);
}
function pickBestTitleFragment(text, { source = 'window', metadata = {} } = {}) {
const fragments = splitTitleFragments(text).filter((line) => isUsefulTitleCandidate(line, { source }));
if (!fragments.length) return '';
let best = '';
let bestScore = -Infinity;
for (const part of fragments) {
const score = scoreCandidate(part, { source });
if (score > bestScore) {
best = part;
bestScore = score;
}
}
return best ? formatCaptureTitle(best, { source, metadata }) : '';
}
function buildCaptureTitle({ mode = 'fullscreen', metadata = {}, ocrText = '', recentTyped = '', recentShortcut = '' } = {}) {
const app = cleanAppName(metadata.appName);
// 1. Keyboard shortcut → most reliable signal for "what action did the user take".
if (recentShortcut && SHORTCUT_TITLES[recentShortcut]) {
const base = SHORTCUT_TITLES[recentShortcut];
return app ? qualifyTitleWithApp(base, metadata.appName) : base;
}
// 2. UIAutomation element value — what's actually typed inside the clicked field.
const elementValue = normalizeWhitespace(metadata.elementValue || '');
if (elementValue) {
const roleLower = normalizeWhitespace(metadata.elementRole || '').toLowerCase();
const labelLower = normalizeWhitespace(metadata.elementLabel || '').toLowerCase();
const looksLikeSearch = /(search|find|query|omnibox|address bar)/.test(roleLower + ' ' + labelLower);
const action = looksLikeSearch ? 'Search for' : 'Type';
const base = `${action} "${elementValue}"`;
return app ? qualifyTitleWithApp(base, metadata.appName) : base;
}
// 3. Keyboard-buffer text (typed between captures) + input role context.
const typed = normalizeWhitespace(recentTyped || '');
if (typed) {
const roleLower = normalizeWhitespace(metadata.elementRole || '').toLowerCase();
const labelLower = normalizeWhitespace(metadata.elementLabel || '').toLowerCase();
const isSearchRole = /(search box|searchbox|search field|search bar|search input)/.test(roleLower);
const looksLikeSearch = isSearchRole || /(search|find|query|omnibox|address bar)/.test(roleLower + ' ' + labelLower);
const isAnyInput = /(text field|edit|input|field|combo box|textbox|text box)/.test(roleLower);
if (looksLikeSearch) {
const base = `Search for "${typed}"`;
return app ? qualifyTitleWithApp(base, metadata.appName) : base;
}
if (isAnyInput) {
const base = `Type "${typed}"`;
return app ? qualifyTitleWithApp(base, metadata.appName) : base;
}
}
// 4. OCR text around the click — link text, button labels, menu items.
const ocrPhrase = pickBestOcrPhrase(ocrText);
if (ocrPhrase) {
const title = formatCaptureTitle(ocrPhrase, { source: 'ocr', metadata });
return app ? qualifyTitleWithApp(title, metadata.appName) : title;
}
// 5. UIAutomation element label.
const elementPhrase = pickBestTitleFragment(metadata.elementLabel, { source: 'element', metadata });
if (elementPhrase) {
return app ? qualifyTitleWithApp(elementPhrase, metadata.appName) : elementPhrase;
}
// 6. Window title (browser suffix + app name stripped) → page title or search query.
const strippedWindowTitle = stripBrowserNameSuffix(metadata.windowTitle || '', metadata.appName);
if (strippedWindowTitle) {
const searchQuery = extractSearchQuery(strippedWindowTitle);
if (searchQuery) {
// Only claim this step IS the search action when the user was actually typing
// (recentTyped). Without typing context, the search page title is from the
// PREVIOUS step — the current step is a click ON the search results page.
if (recentTyped) {
const base = `Search for ${sentenceCase(searchQuery)}`;
return app ? qualifyTitleWithApp(base, metadata.appName) : base;
}
// User is clicking something on the search results page — don't claim they searched.
const base = `Select a ${sentenceCase(searchQuery)} result`;
return app ? qualifyTitleWithApp(base, metadata.appName) : base;
}
const windowPhrase = pickBestTitleFragment(strippedWindowTitle, { source: 'window', metadata });
if (windowPhrase) return windowPhrase;
}
// 7. App name alone as last resort.
const appPhrase = pickBestTitleFragment(metadata.appName, { source: 'app', metadata });
if (appPhrase) return appPhrase;
return DEFAULT_CAPTURE_TITLES[mode] || 'Capture';
}
function plainTextToHtml(text) {
const trimmed = normalizeWhitespace(text);
if (!trimmed) return '';
return trimmed
.split(/\n{2,}/)
.map((para) => `<p>${escapeHtml(para).replace(/\n/g, '<br>')}</p>`)
.join('');
}
function normalizeOllamaHost(host) {
const raw = normalizeWhitespace(host);
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) return raw.replace(/\/+$/, '');
return `http://${raw.replace(/\/+$/, '')}`;
}
function normalizeAiLevel(level) {
const key = normalizeWhitespace(level).toLowerCase();
return AI_LEVEL_ALIASES.get(key) || (TEXTBLOCK_LEVELS.includes(key) ? key : 'info');
}
function normalizeAiPosition(position) {
const key = normalizeWhitespace(position).toLowerCase();
return TEXTBLOCK_POSITIONS.includes(key) ? key : 'after-description';
}
function normalizeAiBlock(block) {
if (!block || typeof block !== 'object') return null;
const kind = normalizeWhitespace(block.kind).toLowerCase();
if (kind === 'text') {
const normalized = normalizeTextBlock({
id: block.id,
order: Number.isFinite(block.order) ? block.order : null,
position: normalizeAiPosition(block.position),
level: normalizeAiLevel(block.level),
title: displayText(block.title),
descriptionHtml: plainTextToHtml(block.body ?? block.description ?? block.text ?? ''),
}, Number.isFinite(block.order) ? block.order : null);
return { ...normalized, kind: 'text' };
}
if (kind === 'code') {
return {
...normalizeCodeBlock({
id: block.id,
order: Number.isFinite(block.order) ? block.order : null,
language: displayText(block.language).toLowerCase(),
code: String(block.code ?? ''),
}, Number.isFinite(block.order) ? block.order : null),
kind: 'code',
};
}
if (kind === 'table') {
const rows = Array.isArray(block.rows)
? block.rows.map((row) => (Array.isArray(row) ? row.map((cell) => displayText(cell)) : []))
: [];
return {
...normalizeTableBlock({
id: block.id,
order: Number.isFinite(block.order) ? block.order : null,
rows,
}, Number.isFinite(block.order) ? block.order : null),
kind: 'table',
};
}
return null;
}
function normalizeAiPatch(raw) {
let data = raw;
if (typeof raw === 'string') {
const trimmed = raw.trim().replace(/^```(?:json)?\s*|\s*```$/g, '');
const start = trimmed.indexOf('{');
const end = trimmed.lastIndexOf('}');
const jsonText = start >= 0 && end > start ? trimmed.slice(start, end + 1) : trimmed;
data = JSON.parse(jsonText);
}
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('AI response must be a JSON object');
}
const out = {
title: displayText(data.title),
descriptionHtml: plainTextToHtml(data.description ?? data.descriptionText ?? ''),
blocks: Array.isArray(data.blocks)
? data.blocks.map((block) => normalizeAiBlock(block)).filter(Boolean)
: [],
};
return out;
}
function summarizeBlocks(step = {}) {
const parts = [];
for (const block of step.textBlocks || []) {
const body = htmlToText(block.descriptionHtml || '');
parts.push(`- Text (${block.level || 'info'}, ${block.position || 'after-description'}): ${block.title || ''}${body ? `${body}` : ''}`.trim());
}
for (const block of step.codeBlocks || []) {
const code = String(block.code || '').trim();
parts.push(`- Code (${block.language || 'plain'}):\n${code || '(empty)'}`);
}
for (const block of step.tableBlocks || []) {
const rows = Array.isArray(block.rows) ? block.rows.length : 0;
const cols = rows > 0 && Array.isArray(block.rows[0]) ? block.rows[0].length : 0;
parts.push(`- Table (${rows}x${cols})`);
}
return parts.length ? parts.join('\n') : '(none)';
}
const DEFAULT_PLACEHOLDER_TITLES = new Set(
Object.values(DEFAULT_CAPTURE_TITLES).concat(['Capture', 'Untitled step']),
);
function isPlaceholderTitle(title) {
return !title || DEFAULT_PLACEHOLDER_TITLES.has(title);
}
function summarizeStepForAi(step = {}) {
const titleLine = isPlaceholderTitle(step.title)
? 'Step title: (not set — generate a specific action title from the capture context)'
: `Step title: ${step.title}`;
const descText = htmlToText(step.descriptionHtml || '');
return [
titleLine,
`Step description: ${descText || '(empty)'}`,
`Step status: ${step.status || 'todo'}`,
`Blocks:\n${summarizeBlocks(step)}`,
].join('\n');
}
function summarizeGuideForAi(guide = {}) {
return [
`Guide title: ${guide.title || '(untitled)'}`,
`Guide description: ${htmlToText(guide.descriptionHtml || '') || '(empty)'}`,
].join('\n');
}
function hasRichCaptureContext(captureContext) {
if (!captureContext) return false;
const ocr = normalizeWhitespace(captureContext.ocrText || '');
const win = normalizeWhitespace(captureContext.windowTitle || '');
const app = normalizeWhitespace(captureContext.appName || '');
const element = normalizeWhitespace(captureContext.elementLabel || '');
// Any non-trivial context signal is enough — even just an app name.
return ocr.length > 3 || win.length > 2 || app.length > 1 || element.length > 1;
}
function buildAiPrompt({
target = 'all',
guide = null,
step = null,
captureContext = null,
block = null,
screenshotAttached = false,
} = {}) {
const hasDraftTitle = step && !isPlaceholderTitle(step.title);
const hasDraftDesc = step && Boolean(htmlToText(step.descriptionHtml || ''));
const targetText = {
title: hasDraftTitle
? 'improve the user\'s draft step title — keep their intent, make it read like professional documentation'
: 'write a specific action title for this step using the capture context',
description: hasDraftDesc
? 'improve the user\'s draft description — keep their intent, make it read like professional documentation'
: 'write a 12 sentence description of what the user does in this step, using the capture context',
block: 'rewrite only the target block',
all: 'write the step title and description from the capture context',
}[target] || 'rewrite the step';
const richContext = hasRichCaptureContext(captureContext);
const allowedBlockNote = target === 'block' ? [
'Use block.kind = "text" with level in [info, warn, error, success] for note / warning / important / tip blocks.',
'Use block.kind = "code" for code snippets.',
'Use block.kind = "table" for tables, with rows as arrays of strings.',
'Use block.position values from [before-title, after-title, before-image, after-image, before-description, after-description].',
].join(' ') : null;
// When the user already has a draft, surface it prominently so the model
// knows exactly what text to polish rather than generating from scratch.
const descText = htmlToText(step?.descriptionHtml || '');
const draftTitleLine = hasDraftTitle && (target === 'title' || target === 'all')
? `User's draft title (rewrite this): "${step.title}"` : null;
const draftDescLine = hasDraftDesc && (target === 'description' || target === 'all')
? `User's draft description (rewrite this): "${descText}"` : null;
const contextLines = [
...(captureContext ? [
captureContext.windowTitle ? `Active window: ${captureContext.windowTitle}` : null,
captureContext.appName ? `App: ${captureContext.appName}` : null,
captureContext.elementLabel ? `UI element: ${captureContext.elementLabel}${captureContext.elementRole ? ` (${captureContext.elementRole})` : ''}` : null,
captureContext.elementValue ? `Element content (what was typed): ${captureContext.elementValue}` : null,
captureContext.recentTyped ? `Keyboard input before this step: ${captureContext.recentTyped}` : null,
captureContext.recentShortcut ? `Keyboard shortcut used: ${captureContext.recentShortcut}` : null,
captureContext.ocrText ? `OCR text near click:\n${captureContext.ocrText}` : null,
(!hasDraftTitle || target === 'description') && captureContext.titleCandidate
? `Suggested title: ${captureContext.titleCandidate}` : null,
] : []),
screenshotAttached ? 'Screenshot: attached to this request.' : null,
draftTitleLine,
draftDescLine,
].filter(Boolean);
const prompt = [
'You write concise, action-focused step-by-step documentation for a desktop application guide.',
'Return JSON only. No markdown fences, no commentary, no extra keys outside the schema below.',
'Schema:',
target === 'block' ? [
'{',
' "title": string,',
' "description": string,',
' "blocks": [{',
' "kind": "text" | "code" | "table",',
' "position"?: "before-title" | "after-title" | "before-image" | "after-image" | "before-description" | "after-description",',
' "level"?: "info" | "warn" | "error" | "success",',
' "title"?: string,',
' "body"?: string,',
' "language"?: string,',
' "code"?: string,',
' "rows"?: string[][]',
' }]',
'}',
].join('\n') : '{ "title": string, "description": string }',
'',
`Target: ${targetText}.`,
allowedBlockNote,
'',
guide ? summarizeGuideForAi(guide) : 'Guide: (not provided)',
'',
step ? summarizeStepForAi(step) : 'Step: (not provided)',
'',
contextLines.length
? `Capture context:\n${contextLines.join('\n')}`
: 'Capture context: (not available)',
'',
block ? `Target block:\n${JSON.stringify(block, null, 2)}` : null,
'',
'Rules:',
'- Titles must be short imperative actions: "Click Save", "Select New document", "Open Settings".',
'- NEVER output "Screen capture", "Window capture", "Region capture", or "Capture" as a title — always produce something specific.',
hasDraftTitle && (target === 'title' || target === 'all')
? '- The user wrote their own title (shown above). Your only job is to polish its grammar and phrasing. Do NOT replace it with something different. Do NOT change what action or subject it describes.'
: '- No title yet. Use the capture context (OCR text, window, app) to write a specific action title.',
hasDraftDesc && (target === 'description' || target === 'all')
? '- The user wrote their own description (shown above). Polish the wording to sound professional but preserve every fact and intent they stated.'
: '- No description yet. Write 12 sentences describing exactly what the user does.',
target === 'block'
? '- Only include blocks that provide genuinely useful supplemental information (warnings, tips, code).'
: '- Do NOT add any blocks array. Only output "title" and "description".',
richContext
? '- Use the OCR text, window title, app name, and element info to make the documentation specific.'
: '- Context is limited. Use the app name or window title if available; generate a reasonable action title.',
screenshotAttached
? '- A screenshot is attached. Use it together with the OCR and metadata to resolve visual details, but do not mention the screenshot in the output.'
: '- No screenshot is attached. Rely on OCR, the window title, app name, and element info.',
'- Do NOT generate blocks that describe the technical capture process or mention OCR.',
'- Do NOT invent details not supported by the capture context.',
'- If the target is one block, only rewrite that block.',
].filter((l) => l !== null).join('\n');
return {
systemPrompt: 'You are a technical documentation writer. Emit only valid JSON matching the schema. Never add commentary or markdown.',
prompt,
};
}
function applyAiPatchToStep(step, patch, { target = 'all', blockId = null } = {}) {
const next = deepClone(step);
if ((target === 'all' || target === 'title') && patch.title) {
next.title = displayText(patch.title);
}
if ((target === 'all' || target === 'description') && patch.descriptionHtml) {
next.descriptionHtml = sanitizeHtml(patch.descriptionHtml);
}
if (target === 'all' && Array.isArray(patch.blocks) && patch.blocks.length) {
const textBlocks = [];
const codeBlocks = [];
const tableBlocks = [];
let nextOrder = 1;
for (const block of patch.blocks) {
const clone = deepClone(block);
clone.order = nextOrder++;
if (clone.kind === 'text') textBlocks.push(clone);
else if (clone.kind === 'code') codeBlocks.push(clone);
else if (clone.kind === 'table') tableBlocks.push(clone);
}
next.textBlocks = textBlocks;
next.codeBlocks = codeBlocks;
next.tableBlocks = tableBlocks;
} else if (target === 'block' && blockId && Array.isArray(patch.blocks) && patch.blocks.length) {
const replacement = patch.blocks[0];
const textBlock = (next.textBlocks || []).find((block) => block.id === blockId);
const codeBlock = (next.codeBlocks || []).find((block) => block.id === blockId);
const tableBlock = (next.tableBlocks || []).find((block) => block.id === blockId);
if (textBlock && replacement.kind === 'text') {
if (replacement.position) textBlock.position = replacement.position;
if (replacement.level) textBlock.level = replacement.level;
if (replacement.title) textBlock.title = replacement.title;
if (replacement.descriptionHtml) textBlock.descriptionHtml = sanitizeHtml(replacement.descriptionHtml);
} else if (codeBlock && replacement.kind === 'code') {
if (replacement.language) codeBlock.language = replacement.language;
if (replacement.code) codeBlock.code = replacement.code;
} else if (tableBlock && replacement.kind === 'table') {
if (replacement.rows) tableBlock.rows = replacement.rows;
}
}
if (!next.image) {
const hasBody = Boolean(
next.title ||
htmlToText(next.descriptionHtml || '') ||
(next.textBlocks || []).length ||
(next.codeBlocks || []).length ||
(next.tableBlocks || []).length,
);
if (hasBody) next.kind = 'content';
}
return next;
}
module.exports = {
DEFAULT_CAPTURE_TITLES,
buildCaptureTitle,
plainTextToHtml,
normalizeOllamaHost,
normalizeAiPatch,
buildAiPrompt,
applyAiPatchToStep,
summarizeStepForAi,
summarizeGuideForAi,
displayText,
normalizeWhitespace,
scoreCandidate,
pickBestOcrPhrase,
};
+15
View File
@@ -95,6 +95,20 @@ function clamp(v, min, max) {
return Math.min(max, Math.max(min, v)); return Math.min(max, Math.max(min, v));
} }
// Matches literal markdown-style links typed in the description editor,
// e.g. "[Settings](https://example.com)". Excludes < > so it never spans
// across an existing HTML tag boundary.
const MD_LINK_RE = /\[([^[\]<>]*)\]\(([^()<>]+)\)/g;
/**
* Turn literal "[text](url)" markdown link syntax (as inserted by the
* description editor's Link button) into real <a href> tags, so exporters
* that consume HTML render an actual link instead of the raw brackets.
*/
function linkifyMarkdownLinks(html) {
return String(html || '').replace(MD_LINK_RE, (m, label, href) => `<a href="${escapeHtml(href)}">${label}</a>`);
}
/** Filesystem-safe slug for export folder names like steps-<title>. */ /** Filesystem-safe slug for export folder names like steps-<title>. */
function slugify(text, fallback = 'untitled') { function slugify(text, fallback = 'untitled') {
const slug = String(text || '') const slug = String(text || '')
@@ -116,6 +130,7 @@ module.exports = {
readJsonSync, readJsonSync,
readJsonIfExists, readJsonIfExists,
htmlToText, htmlToText,
linkifyMarkdownLinks,
decodeEntities, decodeEntities,
escapeHtml, escapeHtml,
escapeXml, escapeXml,
+1
View File
@@ -81,6 +81,7 @@ guide.json + step.json + settings
▼ hidden/skipped, focused-view geometry) ▼ hidden/skipped, focused-view geometry)
Render AST ──► exporters/json.js .json + steps-<title>/ images Render AST ──► exporters/json.js .json + steps-<title>/ images
──► exporters/markdown.js .md + steps-<title>/ images ──► exporters/markdown.js .md + steps-<title>/ images
──► exporters/wikijs.js .md + steps-<title>/ images
──► exporters/html-simple.js single self-contained .html ──► exporters/html-simple.js single self-contained .html
──► exporters/html-rich.js checkboxes + floating TOC ──► exporters/html-rich.js checkboxes + floating TOC
──► exporters/pdf.js native PDF writer (core/pdf.js) ──► exporters/pdf.js native PDF writer (core/pdf.js)
+2 -3
View File
@@ -16,9 +16,8 @@ filing issues — is expected to:
Maintainers may edit, hide, or remove comments, commits, issues, and PRs that Maintainers may edit, hide, or remove comments, commits, issues, and PRs that
violate this standard, and may temporarily or permanently ban contributors violate this standard, and may temporarily or permanently ban contributors
for repeated or egregious behavior. for egregious behavior. We may do this at any time without warning and without chance for appeal.
## Reporting ## Reporting
Report unacceptable behavior privately to the maintainers via the contact Report unacceptable behavior privately to the maintainers via `[email protected]`. Reports are handled confidentially.
listed on the project page. Reports are handled confidentially.
+6 -7
View File
@@ -29,9 +29,10 @@ Origin sign-off (`git commit -s`).
## Offline Rules ## Offline Rules
- No network code paths in the application. No telemetry, update checks, - No network code paths in the application. No telemetry, update checks,
license checks, remote fonts, or remote APIs — ever. license checks, remote fonts, or remote APIs — **ever**.
- No new runtime dependencies without prior maintainer agreement; prefer - No new runtime dependencies without prior maintainer agreement; prefer
internal implementations using Node built-ins. internal implementations using Node built-ins. This is due to all the
security issues that have arrose lately with NPM dependencies.
## Branching ## Branching
@@ -46,8 +47,7 @@ Origin sign-off (`git commit -s`).
`Closes #123`, `Fixes #123`, or `Relates to #123`. `Closes #123`, `Fixes #123`, or `Relates to #123`.
- Summarize the change clearly and call out anything a reviewer should - Summarize the change clearly and call out anything a reviewer should
verify manually. verify manually.
- Update docs when behavior changes, and add a CHANGELOG entry for every - Update docs when behavior changes.
user-visible change.
- Every exporter or storage change **requires tests**; output changes - Every exporter or storage change **requires tests**; output changes
require updated snapshot fixtures under `tests/fixtures/`. require updated snapshot fixtures under `tests/fixtures/`.
@@ -64,10 +64,9 @@ automatically. The shell checks invoke the `node --test` workflow suites in
`tests/unit/`. `tests/unit/`.
Write tests that exercise **real workflows and verify actual output** Write tests that exercise **real workflows and verify actual output**
create a guide, export it, parse the bytes that came out — rather than create a guide, export it, parse the bytes that came out. DO NOT WRITE A TEST THAT GREPS FOR CODE.
grepping for magic strings.
The Gitea workflow in `.gitea/workflows/tests.yaml` runs the same command The Gitea workflow in `.gitea/workflows/tests.yaml` and `.github/workflows/ci.yaml` runs the same command
automatically on pushes and pull requests. automatically on pushes and pull requests.
Please add lots of tests to each of your PR's and be descriptive with the Please add lots of tests to each of your PR's and be descriptive with the
+13 -6
View File
@@ -3,6 +3,12 @@
StepForge is a fully offline desktop app. Nothing is uploaded or synced, and StepForge is a fully offline desktop app. Nothing is uploaded or synced, and
all guides stay on your machine. all guides stay on your machine.
# Windows installation
For the windows installation, please see [windows_installation](windows_installation.md)
# Developer install
## 1. Install ## 1. Install
From the repository root: From the repository root:
@@ -28,7 +34,7 @@ usually under `~/.local/share/stepforge`. On Windows it is usually under
In the library view: In the library view:
1. Click `New guide`. 1. Click `New guide`.
2. Give the guide a clear title. 2. Give the guide a clear title (more -> rename guide).
3. Open the guide to enter the editor. 3. Open the guide to enter the editor.
You can also import a guide archive with `Import archive` if you already have You can also import a guide archive with `Import archive` if you already have
@@ -36,10 +42,11 @@ one.
## 4. Add content ## 4. Add content
There are two simple ways to start: There are three simple ways to start:
1. Import screenshots with the `Import` button in the editor. 1. Record your workflow by clicking on the record button (reconmmended).
2. Paste an image from the clipboard if you already copied one. 2. Import screenshots with the `Import` button in the editor.
3. Paste an image from the clipboard if you already copied one.
If you want to capture new screenshots, open `Quick` actions and start a If you want to capture new screenshots, open `Quick` actions and start a
capture session. Use `Settings` to set the capture hotkey and other capture capture session. Use `Settings` to set the capture hotkey and other capture
@@ -50,7 +57,7 @@ options.
The editor is split into three panes: The editor is split into three panes:
1. Steps on the left 1. Steps on the left
2. Canvas in the center 2. Editing canvas in the center
3. Properties on the right 3. Properties on the right
Use the canvas tools to add shapes, arrows, text, blur, highlight, numbers, Use the canvas tools to add shapes, arrows, text, blur, highlight, numbers,
@@ -86,6 +93,6 @@ If you want to find commands quickly, press `Ctrl+/` for Quick Actions.
## Optional builds ## Optional builds
1. `bash scripts/build-release.sh` assembles the offline release layout. 1. `bash scripts/build-release.sh` assembles the offline release layout.
2. `npm run package:windows` creates the portable Windows `.exe` in 2. `npm run package:windows` creates the Windows installer `.exe` in
`releases/`. `releases/`.
3. `bash scripts/package-linux.sh` creates Linux release artifacts. 3. `bash scripts/package-linux.sh` creates Linux release artifacts.
+5 -369
View File
@@ -1,373 +1,9 @@
Mozilla Public License Version 2.0 Creative Commons Attribution-NonCommercial
==================================
1. Definitions Copyright (c) 2026 Tyler Westbrook
--------------
1.1. "Contributor" Permission is granted to use, copy, modify, and distribute this software for non-commercial purposes only.
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version" You may not sell this software, sell modified versions of it, or use it as part of a commercial product or service without written permission from the copyright holder.
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution" This software is provided "as is", without warranty of any kind.
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
+2 -3
View File
@@ -64,6 +64,5 @@ URLs) before storage and again before rendering or export.
## Reporting ## Reporting
Report vulnerabilities by opening an issue marked `security` (this is a Report vulnerabilities by sending an email to `[email protected]`
local-only tool, so coordinated disclosure pressure is low; do not include
exploit archives directly — describe the structure instead).
+84
View File
@@ -0,0 +1,84 @@
# Getting Started With AI
StepForge keeps AI local. It talks to your own Ollama server on your machine and does not send guide content to the cloud.
## 1. Install Ollama
Install Ollama from https://ollama.com and make sure the service is running.
On most systems you can verify it with:
```bash
ollama --version
```
## 2. Pull a lightweight model
The recommended default is:
```bash
ollama pull llama3.2:1b
```
That model is small enough to feel responsive on modest hardware, but still good enough for human-sounding titles and short text blocks.
If you want StepForge to send the screenshot itself to the model, pull a vision-capable model instead:
```bash
ollama pull gemma3
```
That model can inspect pictures as well as text, so it is better when you want the AI to read the UI directly from the screenshot.
If you need something even smaller, try:
```bash
ollama pull qwen3:0.6b
```
or:
```bash
ollama pull gemma3:270m
```
Those are lighter, but they are usually weaker at writing polished step text.
## 3. Open StepForge settings
In StepForge, open `Settings` and find the `AI` section.
Set:
* `Enable AI text filling` to on
* `Ollama host` to your local Ollama server
* `Ollama model` to `llama3.2:1b` for text-only mode, or `llama3.2-vision` if you want screenshot-aware AI
The default host is:
```text
http://127.0.0.1:11434
```
## 4. Test the connection
Use the `Test connection` button in the AI settings section.
If the model is installed, StepForge should confirm the host and model.
## 5. Use AI manually
AI is never automatic. After capture, use the `AI` button next to:
* the step title
* the step description
* each text, code, and table block
You can also use `More -> Generate all text fields with AI` to fill the whole step in one pass.
## Notes
* Capture titles are still generated automatically without AI.
* AI generation only works when `Enable AI text filling` is turned on.
* The app always uses local OCR around the click area first, then local AI only when you ask for it.
* When the selected Ollama model supports vision, StepForge also sends the screenshot to the model so it can cross-check OCR and visual context.
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 939 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

+100
View File
@@ -0,0 +1,100 @@
# Windows_installation
<div style="height:4px;background:#2563eb;border-radius:999px;margin:12px 0 18px;"></div>
Author: Tyler Westbrook
*10 steps · generated 2026-06-23*
Welcome to StepForge the easy documentation writer! This guide shows you how to setup StepForge on your windows computer step by step. As a demo of this project, this guide was 100% made with StepForge with no outside editing. Enjoy!
## Contents
- [1. Navigate to the git repo.](#step-1)
- [1.1. Click on releases](#step-1-1)
- [1.1.1. Select the latest release for StepForge](#step-1-1-1)
- [2. Run the installer.](#step-2)
- [2.1. Click More Info](#step-2-1)
- [2.1.1. Select Run anyway](#step-2-1-1)
- [2.2. Select install for me or install for all users](#step-2-2)
- [2.3. Select next](#step-2-3)
- [2.4. Select install](#step-2-4)
- [2.5. Select Finish](#step-2-5)
<a id="step-1"></a>
## 1. Navigate to the git repo.
Navigate to the [Github Repository](https://github.com/Twest2/StepForge) located in the link.
![Step 1](steps-windows_installation/001-navigate-to-the-git-repo..png)
<a id="step-1-1"></a>
### 1.1. Click on releases
![Step 1.1](steps-windows_installation/002-click-on-releases.png)
<a id="step-1-1-1"></a>
### 1.1.1. Select the latest release for StepForge
![Step 1.1.1](steps-windows_installation/003-select-the-latest-release-for-stepforge.png)
<div class="sf-callout sf-callout-note" style="border-left: 4px solid #2563eb; padding: 14px 16px; margin: 14px 0; border-radius: 0 16px 16px 0;">
<div style="font-weight: 700; color: #1d4ed8; margin-bottom: 6px;">Note: Newest version</div>
<div style="color: inherit;"><p>Please make sure you select the newest version of StepForge, not v0.2.0. The .exe is an installer that will install the program for you.</p></div>
</div>
<a id="step-2"></a>
## 2. Run the installer.
![Step 2](steps-windows_installation/004-run-the-installer..png)
<a id="step-2-1"></a>
### 2.1. Click More Info
![Step 2.1](steps-windows_installation/005-click-more-info.png)
<div class="sf-callout sf-callout-important" style="border-left: 4px solid #ef4444; padding: 14px 16px; margin: 14px 0; border-radius: 0 16px 16px 0;">
<div style="font-weight: 700; color: #b91c1c; margin-bottom: 6px;">Important: Select More Info</div>
<div style="color: inherit;"><p>Windows is warning you because this installer is new and hasnt built up enough Microsoft SmartScreen reputation yet.</p></div>
</div>
<a id="step-2-1-1"></a>
### 2.1.1. Select Run anyway
![Step 2.1.1](steps-windows_installation/006-select-run-anyway.png)
<a id="step-2-2"></a>
### 2.2. Select install for me or install for all users
![Step 2.2](steps-windows_installation/007-select-install-for-me-or-install-for-all-users.png)
<a id="step-2-3"></a>
### 2.3. Select next
![Step 2.3](steps-windows_installation/008-select-next.png)
<a id="step-2-4"></a>
### 2.4. Select install
![Step 2.4](steps-windows_installation/009-select-install.png)
<a id="step-2-5"></a>
### 2.5. Select Finish
![Step 2.5](steps-windows_installation/010-select-finish.png)
<div class="sf-callout sf-callout-tip" style="border-left: 4px solid #10b981; padding: 14px 16px; margin: 14px 0; border-radius: 0 16px 16px 0;">
<div style="font-weight: 700; color: #047857; margin-bottom: 6px;">Tip: You're finished!</div>
<div style="color: inherit;"><p>Go ahead and play around with StepForge and make some docs!</p></div>
</div>
+50
View File
@@ -55,6 +55,55 @@ function stepBlocks(step) {
return step.blocks || orderedBlocks(step); return step.blocks || orderedBlocks(step);
} }
/**
* Split a step's blocks into the layout buckets exporters use around the
* step title, description, and image. Text blocks keep their block-list
* ordering inside each bucket; code/table blocks stay in `rest`.
*/
function stepContentGroups(step) {
const all = stepBlocks(step);
const groups = {
beforeTitle: [],
afterTitle: [],
beforeDescription: [],
afterDescription: [],
beforeImage: [],
afterImage: [],
rest: [],
};
for (const block of all) {
if (block.kind !== 'text') {
groups.rest.push(block);
continue;
}
switch (block.position) {
case 'before-title':
groups.beforeTitle.push(block);
break;
case 'after-title':
groups.afterTitle.push(block);
break;
case 'before-image':
groups.beforeImage.push(block);
break;
case 'after-image':
groups.afterImage.push(block);
break;
case 'before-description':
groups.beforeDescription.push(block);
break;
case 'after-description':
default:
groups.afterDescription.push(block);
break;
}
}
return groups;
}
function codeBlockText(block) { function codeBlockText(block) {
return blockText(block); return blockText(block);
} }
@@ -67,6 +116,7 @@ module.exports = {
writeStepImages, writeStepImages,
renderAllImages, renderAllImages,
stepBlocks, stepBlocks,
stepContentGroups,
codeBlockText, codeBlockText,
LEVEL_LABEL, LEVEL_LABEL,
}; };
+40 -17
View File
@@ -4,7 +4,8 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { slugify, escapeXml } = require('../core/util'); const { slugify, escapeXml } = require('../core/util');
const { encodePng } = require('../core/png'); const { encodePng } = require('../core/png');
const { guideSlug, renderAllImages, stepBlocks, codeBlockText } = require('./common'); const { guideSlug, renderAllImages, stepContentGroups, codeBlockText } = require('./common');
const { anchorFor, guideMetaLines, guideSummary } = require('./document-layout');
/** /**
* Confluence storage-format export. Writes a single XHTML document plus a * Confluence storage-format export. Writes a single XHTML document plus a
@@ -14,6 +15,7 @@ const { guideSlug, renderAllImages, stepBlocks, codeBlockText } = require('./com
const DEFAULT_TEMPLATE = { const DEFAULT_TEMPLATE = {
includeImages: true, includeImages: true,
toc: true,
}; };
const MACRO_FOR_LEVEL = { const MACRO_FOR_LEVEL = {
@@ -23,10 +25,6 @@ const MACRO_FOR_LEVEL = {
success: 'tip', success: 'tip',
}; };
function anchorFor(step) {
return `step-${step.number.replace(/\./g, '-')}`;
}
function stepLinkRewrite(html, ast) { function stepLinkRewrite(html, ast) {
return String(html || '').replace(/href="step:([^"]+)"/g, (m, id) => { return String(html || '').replace(/href="step:([^"]+)"/g, (m, id) => {
const target = ast.steps.find((s) => s.stepId === id); const target = ast.steps.find((s) => s.stepId === id);
@@ -64,10 +62,25 @@ function exportConfluence(ast, outDir, template = {}) {
} }
const stepXml = ast.steps.map((step) => { const stepXml = ast.steps.map((step) => {
const parts = [`<a id="${anchorFor(step)}"></a>`, `<h2>${escapeXml(step.number)}. ${escapeXml(step.title || 'Untitled step')}</h2>`]; const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
const parts = [`<a id="${anchorFor(step)}"></a>`];
for (const tb of beforeTitle) {
parts.push(blockMacro(tb, ast));
}
parts.push(`<h2>${escapeXml(step.number)}. ${escapeXml(step.title || 'Untitled step')}</h2>`);
if (step.skipped) parts.push('<p><em>(skipped)</em></p>'); if (step.skipped) parts.push('<p><em>(skipped)</em></p>');
for (const tb of afterTitle) {
for (const tb of stepBlocks(step).filter((block) => block.kind === 'text' && block.position === 'before-description')) { parts.push(blockMacro(tb, ast));
}
for (const tb of beforeDescription) {
parts.push(blockMacro(tb, ast)); parts.push(blockMacro(tb, ast));
} }
@@ -75,13 +88,26 @@ function exportConfluence(ast, outDir, template = {}) {
parts.push(`<div>${stepLinkRewrite(step.descriptionHtml, ast)}</div>`); parts.push(`<div>${stepLinkRewrite(step.descriptionHtml, ast)}</div>`);
} }
for (const tb of afterDescription) {
parts.push(blockMacro(tb, ast));
}
for (const tb of beforeImage) {
parts.push(blockMacro(tb, ast));
}
const attachment = attachmentNames.get(step.stepId); const attachment = attachmentNames.get(step.stepId);
if (attachment) { if (attachment) {
parts.push(`<p><ac:image><ri:attachment ri:filename="${escapeXml(attachment)}" /></ac:image></p>`); parts.push(`<p><ac:image><ri:attachment ri:filename="${escapeXml(attachment)}" /></ac:image></p>`);
} }
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) { for (const tb of afterImage) {
if (block.kind === 'code') { parts.push(blockMacro(tb, ast));
}
for (const block of rest) {
if (block.kind === 'text') {
parts.push(blockMacro(block, ast));
} else if (block.kind === 'code') {
const lang = block.language ? `<ac:parameter ac:name="language">${escapeXml(block.language)}</ac:parameter>` : ''; const lang = block.language ? `<ac:parameter ac:name="language">${escapeXml(block.language)}</ac:parameter>` : '';
parts.push(`<ac:structured-macro ac:name="code">${lang}<ac:plain-text-body>${cdata(codeBlockText(block))}</ac:plain-text-body></ac:structured-macro>`); parts.push(`<ac:structured-macro ac:name="code">${lang}<ac:plain-text-body>${cdata(codeBlockText(block))}</ac:plain-text-body></ac:structured-macro>`);
} else if (block.kind === 'table') { } else if (block.kind === 'table') {
@@ -97,13 +123,6 @@ function exportConfluence(ast, outDir, template = {}) {
} }
} }
for (const tb of stepBlocks(step).filter((block) => block.kind === 'text' && block.position === 'after-description')) {
parts.push(blockMacro(tb, ast));
}
for (const tb of stepBlocks(step).filter((block) => block.kind === 'text' && block.position === 'after-image')) {
parts.push(blockMacro(tb, ast));
}
return `<div class="step">${parts.join('\n')}</div>`; return `<div class="step">${parts.join('\n')}</div>`;
}).join('\n'); }).join('\n');
@@ -116,7 +135,11 @@ function exportConfluence(ast, outDir, template = {}) {
</head> </head>
<body> <body>
<h1>${escapeXml(ast.guide.title)}</h1> <h1>${escapeXml(ast.guide.title)}</h1>
<div style="border-bottom: 4px solid #2563eb; margin: 14px 0 18px;"></div>
${guideMetaLines(ast).map((line) => `<p><strong>${escapeXml(line.split(': ')[0])}:</strong> ${escapeXml(line.slice(line.indexOf(': ') + 2))}</p>`).join('\n')}
<p><em>${escapeXml(guideSummary(ast))}</em></p>
${ast.guide.descriptionHtml ? `<div>${stepLinkRewrite(ast.guide.descriptionHtml, ast)}</div>` : ''} ${ast.guide.descriptionHtml ? `<div>${stepLinkRewrite(ast.guide.descriptionHtml, ast)}</div>` : ''}
${tpl.toc && ast.steps.length > 1 ? '<ac:structured-macro ac:name="toc"></ac:structured-macro>' : ''}
${stepXml} ${stepXml}
</body> </body>
</html> </html>
+40
View File
@@ -0,0 +1,40 @@
'use strict';
function anchorFor(stepOrNumber) {
const number = typeof stepOrNumber === 'string'
? stepOrNumber
: stepOrNumber && stepOrNumber.number;
return `step-${String(number || '').replace(/\./g, '-')}`;
}
function tocEntries(ast, { maxDepth = Infinity } = {}) {
return (ast.steps || []).map((step) => ({
step,
anchor: anchorFor(step),
number: step.number,
title: step.title || 'Untitled step',
depth: Math.min(Number.isFinite(step.depth) ? step.depth : 0, maxDepth),
}));
}
function guideMetaLines(ast) {
const meta = ast?.guide?.metadata || {};
return [
meta.author && `Author: ${meta.author}`,
meta.coAuthors && `Co-authors: ${meta.coAuthors}`,
meta.organization && `Organization: ${meta.organization}`,
].filter(Boolean);
}
function guideSummary(ast) {
const count = ast?.steps?.length || 0;
const generated = String(ast?.generatedAt || '').slice(0, 10);
return `${count} step${count === 1 ? '' : 's'} · generated ${generated}`;
}
module.exports = {
anchorFor,
tocEntries,
guideMetaLines,
guideSummary,
};
+235 -29
View File
@@ -5,19 +5,30 @@ const path = require('node:path');
const { zipSync } = require('../core/zip'); const { zipSync } = require('../core/zip');
const { escapeXml } = require('../core/util'); const { escapeXml } = require('../core/util');
const { encodePng } = require('../core/png'); const { encodePng } = require('../core/png');
const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common'); const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
const { guideMetaLines, guideSummary, tocEntries } = require('./document-layout');
/** /**
* DOCX exporter: WordprocessingML built directly (no dependency), one * DOCX exporter: WordprocessingML built directly (no dependency), one
* heading + description + screenshot per step, text blocks, code blocks * heading per step with positioned text blocks around the description and
* (Courier), and tables. * screenshot, plus code blocks (Courier) and tables.
*/ */
const DEFAULT_TEMPLATE = { const DEFAULT_TEMPLATE = {
includeImages: true, includeImages: true,
includeToc: true,
imageWidthTwips: 9000, // ~15.9cm inside A4 margins imageWidthTwips: 9000, // ~15.9cm inside A4 margins
}; };
// Callout styling per text-block level, matching the colors used in the
// HTML/PDF exports so a "Tip" looks distinct from a "Warning" at a glance.
const LEVEL_STYLE = {
info: { fill: 'EFF6FF', color: '1D4ED8' }, // blue — Note
success: { fill: 'ECFDF5', color: '047857' }, // green — Tip
warn: { fill: 'FFFBEB', color: 'B45309' }, // amber — Warning
error: { fill: 'FEF2F2', color: 'B91C1C' }, // red — Important
};
const EMU_PER_PX = 9525; // 96 dpi const EMU_PER_PX = 9525; // 96 dpi
function p(children, props = '') { function p(children, props = '') {
@@ -55,6 +66,74 @@ function drawing(relId, widthPx, heightPx, maxWidthTwips) {
`</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>`; `</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>`;
} }
function pageBreak() {
return p('<w:r><w:br w:type="page"/></w:r>');
}
// Width (in twips) of the text column inside the A4 page margins used by
// <w:sectPr> below (11906 - 1134*2), i.e. where TOC page-number tabs land.
const TOC_TAB_POS = 9638;
/** Bookmark name anchoring a step's heading, referenced by its TOC entry. */
function bookmarkName(step) {
return `toc_${String(step.number).replace(/\./g, '_')}`;
}
/** A `PAGEREF <anchor>` field, cached as "1" until Word recalculates it. */
function pageRefField(anchor) {
return '<w:r><w:fldChar w:fldCharType="begin" w:dirty="true"/></w:r>' +
`<w:r><w:instrText xml:space="preserve"> PAGEREF ${anchor} \\h </w:instrText></w:r>` +
'<w:r><w:fldChar w:fldCharType="separate"/></w:r>' +
'<w:r><w:t>1</w:t></w:r>' +
'<w:r><w:fldChar w:fldCharType="end"/></w:r>';
}
/** One TOC line: hyperlink to the step's heading, dot leader, page number. */
function tocEntryContent(entry) {
const anchor = bookmarkName(entry.step);
return `<w:hyperlink w:anchor="${anchor}">${run(`${entry.number}. ${entry.title}`, { size: 20 })}</w:hyperlink>` +
'<w:r><w:tab/></w:r>' +
pageRefField(anchor);
}
/**
* The TOC as real, navigable entries (one per step) rather than a bare
* "Update contents in Word" placeholder, so the table is correct on first
* open. Still wrapped in a `TOC` field (spanning the first..last paragraph)
* so Word can refresh page numbers via Update Field / the updateFields prompt.
*/
function tocFieldParagraphs(ast) {
const entries = tocEntries(ast);
const beginField = '<w:r><w:fldChar w:fldCharType="begin" w:dirty="true"/></w:r>' +
'<w:r><w:instrText xml:space="preserve"> TOC \\o "1-3" \\h \\z \\u </w:instrText></w:r>' +
'<w:r><w:fldChar w:fldCharType="separate"/></w:r>';
const endField = '<w:r><w:fldChar w:fldCharType="end"/></w:r>';
return entries.map((entry, i) => {
const pPr = `<w:pStyle w:val="TOC${Math.min(3, Math.max(1, entry.depth + 1))}"/>` +
`<w:tabs><w:tab w:val="right" w:leader="dot" w:pos="${TOC_TAB_POS}"/></w:tabs>`;
const lead = i === 0 ? beginField : '';
const trail = i === entries.length - 1 ? endField : '';
return `<w:p><w:pPr>${pPr}</w:pPr>${lead}${tocEntryContent(entry)}${trail}</w:p>`;
});
}
function headingStyleForDepth(depth) {
return `Heading${Math.min(3, depth + 1)}`;
}
function headingOutlineLevelForDepth(depth) {
return Math.min(2, Math.max(0, depth));
}
function headingParagraphProps(depth, forceNewPage = false) {
const parts = [];
if (forceNewPage) parts.push('<w:pageBreakBefore/>');
parts.push(`<w:pStyle w:val="${headingStyleForDepth(depth)}"/>`);
parts.push(`<w:outlineLvl w:val="${headingOutlineLevelForDepth(depth)}"/>`);
return parts.join('');
}
function table(rows) { function table(rows) {
const cols = Math.max(...rows.map((r) => r.length)); const cols = Math.max(...rows.map((r) => r.length));
const grid = `<w:tblGrid>${'<w:gridCol w:w="2400"/>'.repeat(cols)}</w:tblGrid>`; const grid = `<w:tblGrid>${'<w:gridCol w:w="2400"/>'.repeat(cols)}</w:tblGrid>`;
@@ -72,57 +151,179 @@ function table(rows) {
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/>${borders}</w:tblPr>${grid}${body}</w:tbl>`; return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/>${borders}</w:tblPr>${grid}${body}</w:tbl>`;
} }
function stylesXml() {
const headingStyle = (styleId, name, outlineLvl, size, color) => `
<w:style w:type="paragraph" w:styleId="${styleId}">
<w:name w:val="${name}"/>
<w:basedOn w:val="Normal"/>
<w:next w:val="Normal"/>
<w:uiPriority w:val="${9 + outlineLvl}"/>
<w:qFormat/>
<w:unhideWhenUsed/>
<w:pPr>
<w:keepNext/>
<w:keepLines/>
<w:spacing w:before="${outlineLvl === 0 ? 360 : outlineLvl === 1 ? 240 : 180}" w:after="120"/>
<w:outlineLvl w:val="${outlineLvl}"/>
</w:pPr>
<w:rPr>
<w:b/>
<w:sz w:val="${size}"/>
<w:szCs w:val="${size}"/>
<w:color w:val="${color}"/>
</w:rPr>
</w:style>`;
const tocStyle = (level) => `
<w:style w:type="paragraph" w:styleId="TOC${level}">
<w:name w:val="toc ${level}"/>
<w:basedOn w:val="Normal"/>
<w:next w:val="Normal"/>
<w:autoRedefine/>
<w:uiPriority w:val="39"/>
<w:unhideWhenUsed/>
<w:pPr>
<w:spacing w:after="60"/>
<w:ind w:left="${(level - 1) * 360}"/>
<w:tabs><w:tab w:val="right" w:leader="dot" w:pos="${TOC_TAB_POS}"/></w:tabs>
</w:pPr>
<w:rPr>
<w:sz w:val="20"/>
<w:szCs w:val="20"/>
</w:rPr>
</w:style>`;
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:docDefaults>
<w:rPrDefault>
<w:rPr>
<w:sz w:val="22"/>
<w:szCs w:val="22"/>
<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/>
</w:rPr>
</w:rPrDefault>
<w:pPrDefault>
<w:pPr>
<w:spacing w:after="160"/>
</w:pPr>
</w:pPrDefault>
</w:docDefaults>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
<w:name w:val="Normal"/>
<w:qFormat/>
<w:uiPriority w:val="1"/>
<w:rPr>
<w:sz w:val="22"/>
<w:szCs w:val="22"/>
<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/>
</w:rPr>
</w:style>
${headingStyle('Heading1', 'Heading 1', 0, 30, '2563EB')}
${headingStyle('Heading2', 'Heading 2', 1, 26, '1D4ED8')}
${headingStyle('Heading3', 'Heading 3', 2, 22, '1E40AF')}
${tocStyle(1)}
${tocStyle(2)}
${tocStyle(3)}
</w:styles>`;
}
function exportDocx(ast, outDir, template = {}) { function exportDocx(ast, outDir, template = {}) {
const tpl = { ...DEFAULT_TEMPLATE, ...template }; const tpl = { ...DEFAULT_TEMPLATE, ...template };
const images = tpl.includeImages ? renderAllImages(ast) : new Map(); const images = tpl.includeImages ? renderAllImages(ast) : new Map();
const media = []; // { name, data } const media = []; // { name, data }
const rels = []; // relationship XML strings const rels = []; // relationship XML strings
let relCounter = 0; let relCounter = 1; // rId1 reserved for settings.xml; rId2 for styles.xml
let bookmarkCounter = 0;
let stepImageCount = 0;
rels.push(`<Relationship Id="rId${++relCounter}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`);
const body = []; const body = [];
body.push(p(run(ast.guide.title, { bold: true, size: 48 }))); body.push(p(
run(ast.guide.title, { bold: true, size: 48 }),
'<w:pBdr><w:bottom w:val="single" w:sz="24" w:space="12" w:color="2563EB"/></w:pBdr>'
));
if (ast.guide.descriptionText) body.push(p(run(ast.guide.descriptionText, { size: 22, color: '444444' }))); if (ast.guide.descriptionText) body.push(p(run(ast.guide.descriptionText, { size: 22, color: '444444' })));
body.push(p(run(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 18, color: '888888' }))); for (const line of guideMetaLines(ast)) body.push(p(run(line, { size: 20, color: '6B7280' })));
body.push(p(run(guideSummary(ast), { size: 18, color: '888888' })));
body.push(pageBreak());
if (tpl.includeToc && ast.steps.length > 1) {
body.push(p(
run('Contents', { bold: true, size: 28 }),
'<w:pBdr><w:bottom w:val="single" w:sz="20" w:space="8" w:color="2563EB"/></w:pBdr>'
));
body.push(...tocFieldParagraphs(ast));
body.push(pageBreak());
}
const emitTextBlock = (tb) => {
const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info;
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
body.push(p(
`${run(label, { bold: true, size: 20, color: style.color })}${tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20, color: '1F2937' }) : ''}`,
`<w:shd w:val="clear" w:fill="${style.fill}"/><w:pBdr><w:left w:val="single" w:sz="24" w:space="4" w:color="${style.color}"/></w:pBdr>`
));
};
for (const step of ast.steps) { for (const step of ast.steps) {
const headSize = step.depth > 0 ? 26 : 30; const headingLevel = Math.min(3, Math.max(1, step.depth + 1));
body.push(p(run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }), const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22;
step.forceNewPage ? '<w:pageBreakBefore/>' : '')); const bookmarkId = ++bookmarkCounter;
const anchor = bookmarkName(step);
emitTextBlocks(step, 'before-description'); const {
if (step.descriptionText) body.push(p(run(step.descriptionText))); beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
for (const tb of beforeTitle) emitTextBlock(tb);
body.push(p(
`<w:bookmarkStart w:id="${bookmarkId}" w:name="${anchor}"/>` +
run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }) +
`<w:bookmarkEnd w:id="${bookmarkId}"/>`,
headingParagraphProps(step.depth, step.forceNewPage)
));
for (const tb of afterTitle) emitTextBlock(tb);
for (const tb of beforeDescription) emitTextBlock(tb);
if (step.descriptionText) body.push(p(run(step.descriptionText, { size: 20, color: '1F2937' })));
for (const tb of afterDescription) emitTextBlock(tb);
for (const tb of beforeImage) emitTextBlock(tb);
const img = images.get(step.stepId); const img = images.get(step.stepId);
if (img) { if (img) {
relCounter += 1; const relId = ++relCounter;
const name = `image${relCounter}.png`; const name = `image${relCounter}.png`;
media.push({ name, data: encodePng(img) }); media.push({ name, data: encodePng(img) });
rels.push(`<Relationship Id="rId${relCounter}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/${name}"/>`); rels.push(`<Relationship Id="rId${relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/${name}"/>`);
body.push(p(drawing(relCounter, img.width, img.height, tpl.imageWidthTwips))); body.push(p(drawing(relId, img.width, img.height, tpl.imageWidthTwips)));
stepImageCount += 1;
} }
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) { for (const tb of afterImage) emitTextBlock(tb);
if (block.kind === 'code') {
for (const block of rest) {
if (block.kind === 'text') {
emitTextBlock(block);
} else if (block.kind === 'code') {
body.push(p(run(codeBlockText(block), { size: 18, font: 'Courier New', color: '1F2937' }), body.push(p(run(codeBlockText(block), { size: 18, font: 'Courier New', color: '1F2937' }),
'<w:shd w:val="clear" w:fill="F3F4F6"/>')); '<w:shd w:val="clear" w:fill="F3F4F6"/>'));
} else if (block.kind === 'table') { } else if (block.kind === 'table') {
if (block.rows && block.rows.length) body.push(table(block.rows), p('')); if (block.rows && block.rows.length) body.push(table(block.rows), p(''));
} }
} }
emitTextBlocks(step, 'after-description');
emitTextBlocks(step, 'after-image');
} }
function emitTextBlocks(step, position) { const settingsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) { <w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; <w:updateFields w:val="true"/>
body.push(p( </w:settings>`;
run(label, { bold: true, size: 20 }) + (tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20 }) : ''),
'<w:shd w:val="clear" w:fill="F9FAFB"/>'
));
}
}
const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
@@ -143,6 +344,8 @@ ${body.join('\n')}
<Default Extension="xml" ContentType="application/xml"/> <Default Extension="xml" ContentType="application/xml"/>
<Default Extension="png" ContentType="image/png"/> <Default Extension="png" ContentType="image/png"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/> <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
<Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>
</Types>`, </Types>`,
}, },
{ {
@@ -157,16 +360,19 @@ ${body.join('\n')}
name: 'word/_rels/document.xml.rels', name: 'word/_rels/document.xml.rels',
data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>
${rels.join('\n')} ${rels.join('\n')}
</Relationships>`, </Relationships>`,
}, },
{ name: 'word/settings.xml', data: settingsXml },
{ name: 'word/styles.xml', data: stylesXml() },
...media.map((m) => ({ name: `word/media/${m.name}`, data: m.data, store: true })), ...media.map((m) => ({ name: `word/media/${m.name}`, data: m.data, store: true })),
]; ];
fs.mkdirSync(outDir, { recursive: true }); fs.mkdirSync(outDir, { recursive: true });
const file = path.join(outDir, `${guideSlug(ast)}.docx`); const file = path.join(outDir, `${guideSlug(ast)}.docx`);
fs.writeFileSync(file, zipSync(entries)); fs.writeFileSync(file, zipSync(entries));
return { file, imageCount: media.length }; return { file, imageCount: stepImageCount };
} }
module.exports = { exportDocx, DEFAULT_TEMPLATE }; module.exports = { exportDocx, DEFAULT_TEMPLATE };
+487 -82
View File
@@ -4,7 +4,8 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { escapeHtml } = require('../core/util'); const { escapeHtml } = require('../core/util');
const { encodePng } = require('../core/png'); const { encodePng } = require('../core/png');
const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common'); const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
const { anchorFor, tocEntries, guideMetaLines, guideSummary } = require('./document-layout');
/** /**
* HTML exporters. Both variants are fully self-contained single files: * HTML exporters. Both variants are fully self-contained single files:
@@ -18,14 +19,11 @@ const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = r
const DEFAULT_TEMPLATE = { const DEFAULT_TEMPLATE = {
includeImages: true, includeImages: true,
toc: true,
accentColor: '#2563eb', accentColor: '#2563eb',
customCss: '', customCss: '',
}; };
function anchorFor(step) {
return `step-${step.number.replace(/\./g, '-')}`;
}
function dataUri(img) { function dataUri(img) {
return `data:image/png;base64,${encodePng(img).toString('base64')}`; return `data:image/png;base64,${encodePng(img).toString('base64')}`;
} }
@@ -38,56 +36,477 @@ function stepLinkRewrite(html, ast) {
}); });
} }
function blocksHtml(step, position) { function blockHtml(tb) {
return stepBlocks(step) return `<div class="block block-${tb.level}"><strong>${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}</strong>${tb.descriptionHtml ? `<div>${tb.descriptionHtml}</div>` : ''}</div>`;
.filter((tb) => tb.position === position)
.map((tb) => `<div class="block block-${tb.level}"><strong>${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}</strong>${tb.descriptionHtml ? `<div>${tb.descriptionHtml}</div>` : ''}</div>`)
.join('\n');
} }
function stepBodyHtml(step, ast, images, tpl) { function renderBlockListHtml(blocks) {
return blocks.map((block) => blockHtml(block)).join('\n');
}
function renderMetaChips(ast) {
return [
...guideMetaLines(ast).map((line) => `<span class="chip">${escapeHtml(line)}</span>`),
`<span class="chip muted">${escapeHtml(guideSummary(ast))}</span>`,
].join('');
}
function renderTocList(ast) {
return tocEntries(ast).map((entry) => `
<li class="d${entry.depth}">
<a href="#${entry.anchor}">
<span class="num">${escapeHtml(entry.number)}</span>
<span class="label">${escapeHtml(entry.title)}</span>
</a>
</li>
`).join('');
}
function renderCover(ast, tpl) {
return `
<section class="cover">
<div class="eyebrow">StepForge export</div>
<h1>${escapeHtml(ast.guide.title)}</h1>
<div class="rule" style="background:${tpl.accentColor}"></div>
${ast.guide.descriptionHtml ? `<div class="desc">${stepLinkRewrite(ast.guide.descriptionHtml, ast)}</div>` : ''}
<div class="meta">${renderMetaChips(ast)}</div>
</section>`;
}
function renderStepCard(step, ast, images, tpl, { rich = false, selected = false } = {}) {
const title = `${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}`;
const statusText = step.skipped
? 'Skipped'
: step.status === 'in-progress'
? 'In progress'
: step.status === 'done'
? 'Done'
: 'Todo';
const head = rich ? `
<h2>
<label class="check"><input type="checkbox" class="step-done" data-step="${escapeHtml(step.stepId)}"></label>
<span class="step-num">${escapeHtml(step.number)}</span>
<span class="step-title">${escapeHtml(step.title || 'Untitled step')}</span>
<span class="status-chip status-${step.status || 'todo'}${step.skipped ? ' skipped' : ''}">${escapeHtml(statusText)}</span>
</h2>` : `<h2>${title}</h2>`;
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
return `
<section class="step-card${step.skipped ? ' skipped' : ''}${rich && selected ? ' selected' : ''}" id="${anchorFor(step)}">
${renderBlockListHtml(beforeTitle)}
${head}
${renderBlockListHtml(afterTitle)}
<div class="step-body">
${renderStepBodyHtml({ beforeDescription, afterDescription, beforeImage, afterImage, rest }, step, ast, images, tpl)}
</div>
</section>`;
}
function renderRichToc(ast) {
return tocEntries(ast).map((entry) => `
<li class="d${entry.depth}">
<a href="#${entry.anchor}">
<span class="num">${escapeHtml(entry.number)}</span>
<span class="label">${escapeHtml(entry.title)}</span>
</a>
</li>
`).join('');
}
function hexToRgb(hex) {
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim());
if (!m) return [37, 99, 235];
const n = Number.parseInt(m[1], 16);
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
}
function bodyStyle(tpl) {
const [r, g, b] = hexToRgb(tpl.accentColor);
return `--accent:${tpl.accentColor};--accent-rgb:${r},${g},${b};`;
}
function renderStepBodyHtml(groups, step, ast, images, tpl) {
const parts = []; const parts = [];
parts.push(blocksHtml(step, 'before-description')); const {
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = groups;
for (const tb of beforeDescription) parts.push(blockHtml(tb));
if (step.descriptionHtml) parts.push(`<div class="desc">${stepLinkRewrite(step.descriptionHtml, ast)}</div>`); if (step.descriptionHtml) parts.push(`<div class="desc">${stepLinkRewrite(step.descriptionHtml, ast)}</div>`);
for (const tb of afterDescription) parts.push(blockHtml(tb));
for (const tb of beforeImage) parts.push(blockHtml(tb));
const img = images.get(step.stepId); const img = images.get(step.stepId);
if (img && tpl.includeImages) { if (img && tpl.includeImages) {
parts.push(`<img class="shot" alt="Step ${escapeHtml(step.number)}" src="${dataUri(img)}" width="${img.width}">`); parts.push(`<img class="shot" alt="Step ${escapeHtml(step.number)}" src="${dataUri(img)}" width="${img.width}">`);
} }
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) { for (const tb of afterImage) parts.push(blockHtml(tb));
if (block.kind === 'code') { for (const block of rest) {
if (block.kind === 'text') {
parts.push(blockHtml(block));
} else if (block.kind === 'code') {
parts.push(`<pre class="code"><code>${escapeHtml(codeBlockText(block))}</code></pre>`); parts.push(`<pre class="code"><code>${escapeHtml(codeBlockText(block))}</code></pre>`);
} else if (block.kind === 'table') { } else if (block.kind === 'table') {
if (!block.rows || !block.rows.length) continue; if (!block.rows || !block.rows.length) continue;
const [head, ...rest] = block.rows; const [head, ...bodyRows] = block.rows;
parts.push('<table><thead><tr>' + head.map((c) => `<th>${escapeHtml(c)}</th>`).join('') + '</tr></thead><tbody>' parts.push('<table><thead><tr>' + head.map((c) => `<th>${escapeHtml(c)}</th>`).join('') + '</tr></thead><tbody>'
+ rest.map((r) => '<tr>' + r.map((c) => `<td>${escapeHtml(c)}</td>`).join('') + '</tr>').join('') + bodyRows.map((r) => '<tr>' + r.map((c) => `<td>${escapeHtml(c)}</td>`).join('') + '</tr>').join('')
+ '</tbody></table>'); + '</tbody></table>');
} }
} }
parts.push(blocksHtml(step, 'after-description'));
parts.push(blocksHtml(step, 'after-image'));
return parts.filter(Boolean).join('\n'); return parts.filter(Boolean).join('\n');
} }
const BASE_CSS = ` const BASE_CSS = `
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; margin: 0 auto; max-width: 860px; :root {
padding: 24px; color: #1f2937; background: #ffffff; line-height: 1.55; } --bg: #f4f7fb;
h1 { font-size: 1.7em; margin-bottom: .2em; } --bg-2: #eef3f9;
h2 { font-size: 1.2em; margin-top: 1.6em; border-bottom: 1px solid #e5e7eb; padding-bottom: .25em; } --panel: rgba(255, 255, 255, 0.92);
img.shot { max-width: 100%; height: auto; border: 1px solid #e5e7eb; border-radius: 6px; margin: .6em 0; } --panel-strong: #ffffff;
pre.code { background: #f3f4f6; padding: 12px; border-radius: 6px; overflow-x: auto; } --panel-soft: #f8fbff;
table { border-collapse: collapse; margin: .6em 0; } --text: #152033;
th, td { border: 1px solid #d1d5db; padding: 4px 10px; text-align: left; } --muted: #637084;
.block { border-left: 4px solid #9ca3af; background: #f9fafb; padding: 8px 12px; margin: .6em 0; border-radius: 0 6px 6px 0; } --border: rgba(119, 134, 156, 0.22);
--shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
}
html { scroll-behavior: smooth; }
body {
margin: 0;
min-height: 100vh;
color: var(--text);
background:
radial-gradient(circle at top right, rgba(var(--accent-rgb), 0.16), transparent 26%),
radial-gradient(circle at bottom left, rgba(14, 165, 233, 0.11), transparent 22%),
linear-gradient(180deg, var(--bg), var(--bg-2));
line-height: 1.6;
font-family: "Aptos", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
img.shot {
max-width: 100%;
height: auto;
border: 1px solid var(--border);
border-radius: 18px;
margin: 16px 0;
box-shadow: var(--shadow);
}
pre.code {
background: #0f172a;
color: #e2e8f0;
padding: 16px 18px;
border-radius: 18px;
overflow-x: auto;
box-shadow: var(--shadow);
}
pre.code code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; }
table {
width: 100%;
border-collapse: collapse;
margin: 14px 0;
background: var(--panel-strong);
border: 1px solid var(--border);
border-radius: 16px;
overflow: hidden;
box-shadow: var(--shadow);
}
th, td {
border-bottom: 1px solid rgba(119, 134, 156, 0.16);
padding: 10px 12px;
text-align: left;
}
thead th {
background: var(--panel-soft);
color: var(--text);
font-size: .88rem;
letter-spacing: .02em;
text-transform: uppercase;
}
tbody tr:last-child td { border-bottom: 0; }
.doc { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 24px 0 40px; }
.cover, .toc-card, .step-card, .progress-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 24px;
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}
.cover {
padding: 34px 36px 28px;
margin-bottom: 22px;
}
.cover .eyebrow {
color: var(--accent);
text-transform: uppercase;
letter-spacing: .16em;
font-size: .74rem;
font-weight: 800;
margin-bottom: 14px;
}
.cover h1 {
font-size: clamp(2rem, 4vw, 3.6rem);
line-height: 1.02;
margin: 0;
letter-spacing: -0.04em;
}
.cover .rule {
width: 132px;
height: 6px;
margin: 18px 0 16px;
border-radius: 999px;
box-shadow: 0 10px 24px rgba(var(--accent-rgb), 0.22);
}
.cover .desc {
max-width: 76ch;
color: var(--muted);
font-size: 1.02rem;
}
.cover .meta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 11px;
border-radius: 999px;
background: rgba(var(--accent-rgb), 0.10);
color: var(--accent);
border: 1px solid rgba(var(--accent-rgb), 0.14);
font-size: .86rem;
font-weight: 650;
}
.chip.muted {
background: rgba(255, 255, 255, 0.55);
color: var(--muted);
border-color: rgba(119, 134, 156, 0.16);
}
.toc-card {
padding: 20px 22px;
margin-bottom: 24px;
}
.toc-card h2 {
margin: 0 0 16px;
font-size: 1.08rem;
letter-spacing: .02em;
}
.toc-list {
list-style: none;
margin: 0;
padding: 0;
}
.toc-list li { margin: 0; }
.toc-list a {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 9px 12px;
border-radius: 14px;
color: inherit;
}
.toc-list a:hover {
background: rgba(var(--accent-rgb), 0.08);
text-decoration: none;
}
.toc-list .num {
min-width: 44px;
color: var(--accent);
font-weight: 800;
}
.toc-list .label { flex: 1; }
.toc-list li.d1 a { padding-left: 24px; }
.toc-list li.d2 a { padding-left: 44px; }
.toc-list li.d3 a { padding-left: 64px; }
.step-card {
margin: 20px 0;
padding: 22px 24px 18px;
border-left: 6px solid var(--accent);
}
.step-card h2 {
display: flex;
align-items: flex-start;
gap: 12px;
margin: 0 0 16px;
font-size: 1.28rem;
line-height: 1.15;
}
.step-card h2 .step-num {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.1rem;
height: 2.1rem;
padding: 0 10px;
border-radius: 999px;
background: rgba(var(--accent-rgb), 0.11);
color: var(--accent);
font-size: .9rem;
font-weight: 800;
letter-spacing: .02em;
flex: none;
}
.step-card h2 .step-title { flex: 1; }
.status-chip {
display: inline-flex;
align-items: center;
height: 1.65rem;
padding: 0 9px;
border-radius: 999px;
font-size: .72rem;
letter-spacing: .08em;
text-transform: uppercase;
font-weight: 800;
color: #334155;
background: var(--panel-soft);
border: 1px solid rgba(119, 134, 156, 0.18);
}
.status-chip.status-done {
color: #047857;
background: #ecfdf5;
border-color: rgba(16, 185, 129, 0.22);
}
.status-chip.status-in-progress {
color: #b45309;
background: #fffbeb;
border-color: rgba(245, 158, 11, 0.22);
}
.status-chip.status-todo {
color: #475569;
}
.status-chip.skipped {
color: #92400e;
background: #fffbeb;
border-color: rgba(245, 158, 11, 0.24);
}
.step-card.skipped { opacity: .76; }
.step-body > .desc {
color: #243044;
}
.block {
position: relative;
border-left: 4px solid var(--accent);
background: rgba(var(--accent-rgb), 0.08);
padding: 14px 16px 14px 54px;
margin: 14px 0;
border-radius: 18px;
overflow: hidden;
}
.block::before {
content: 'i';
position: absolute;
left: 16px;
top: 14px;
width: 22px;
height: 22px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(var(--accent-rgb), 0.15);
color: var(--accent);
font-weight: 900;
font-size: 13px;
}
.block strong { color: var(--text); }
.block-warn { border-color: #f59e0b; background: #fffbeb; } .block-warn { border-color: #f59e0b; background: #fffbeb; }
.block-warn::before { content: '!'; background: rgba(245, 158, 11, 0.16); color: #b45309; }
.block-warn strong { color: #92400e; }
.block-error { border-color: #ef4444; background: #fef2f2; } .block-error { border-color: #ef4444; background: #fef2f2; }
.block-error::before { content: '!'; background: rgba(239, 68, 68, 0.14); color: #b91c1c; }
.block-error strong { color: #b91c1c; }
.block-success { border-color: #10b981; background: #ecfdf5; } .block-success { border-color: #10b981; background: #ecfdf5; }
.block-success::before { content: '✓'; background: rgba(16, 185, 129, 0.14); color: #047857; }
.block-success strong { color: #047857; }
.footer-note {
margin-top: 20px;
color: var(--muted);
font-size: .84rem;
}
.skipped { opacity: .55; } .skipped { opacity: .55; }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { background: #111827; color: #e5e7eb; } :root {
h2 { border-color: #374151; } --bg: #0b1220;
pre.code, .block { background: #1f2937; } --bg-2: #0f172a;
th, td { border-color: #4b5563; } --panel: rgba(17, 24, 39, 0.9);
--panel-strong: #111827;
--panel-soft: #162033;
--text: #e5e7eb;
--muted: #98a2b3;
--border: rgba(148, 163, 184, 0.18);
--shadow: 0 16px 40px rgba(0, 0, 0, 0.25);
}
.status-chip { color: #d1d5db; }
.status-chip.skipped { color: #fbbf24; background: rgba(245, 158, 11, 0.12); }
.cover .desc, .step-body > .desc { color: #cbd5e1; }
.chip.muted { background: rgba(15, 23, 42, 0.75); color: #cbd5e1; }
.block { background: rgba(37, 99, 235, 0.12); }
.block-warn { background: rgba(245, 158, 11, 0.12); }
.block-error { background: rgba(239, 68, 68, 0.12); }
.block-success { background: rgba(16, 185, 129, 0.12); }
table { background: var(--panel-strong); }
th, td { border-bottom-color: rgba(148, 163, 184, 0.12); }
}
`;
const RICH_CSS = `
.layout-rich {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.toc-panel {
position: sticky;
top: 20px;
align-self: start;
}
.toc-card {
margin: 0;
max-height: calc(100vh - 40px);
overflow: auto;
}
.progress-card {
margin-bottom: 22px;
padding: 14px 18px;
}
.progress {
position: sticky;
top: 0;
z-index: 2;
background: transparent;
}
.progress .label {
font-size: .86rem;
font-weight: 700;
color: var(--muted);
margin-bottom: 8px;
}
.progress .bar {
height: 8px;
background: rgba(148, 163, 184, 0.18);
border-radius: 999px;
overflow: hidden;
}
.progress .fill {
height: 100%;
width: 0;
background: var(--accent);
transition: width .2s ease;
}
label.check { margin-right: 0; }
label.check input { transform: translateY(1px); }
.step-card h2 .step-title { min-width: 0; }
@media (max-width: 960px) {
.layout-rich { grid-template-columns: 1fr; }
.toc-panel { position: static; }
.toc-card { max-height: none; }
} }
`; `;
@@ -95,12 +514,16 @@ function exportHtmlSimple(ast, outDir, template = {}) {
const tpl = { ...DEFAULT_TEMPLATE, ...template }; const tpl = { ...DEFAULT_TEMPLATE, ...template };
fs.mkdirSync(outDir, { recursive: true }); fs.mkdirSync(outDir, { recursive: true });
const images = tpl.includeImages ? renderAllImages(ast) : new Map(); const images = tpl.includeImages ? renderAllImages(ast) : new Map();
const toc = tpl.toc && ast.steps.length > 1
const stepsHtml = ast.steps.map((step) => ` ? `
<section class="step${step.skipped ? ' skipped' : ''}" id="${anchorFor(step)}"> <section class="toc-card">
<h2>${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}</h2> <h2>Contents</h2>
${stepBodyHtml(step, ast, images, tpl)} <ul class="toc-list">
</section>`).join('\n'); ${renderTocList(ast)}
</ul>
</section>`
: '';
const stepsHtml = ast.steps.map((step) => renderStepCard(step, ast, images, tpl)).join('\n');
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -110,11 +533,13 @@ function exportHtmlSimple(ast, outDir, template = {}) {
<title>${escapeHtml(ast.guide.title)}</title> <title>${escapeHtml(ast.guide.title)}</title>
<style>${BASE_CSS}${tpl.customCss}</style> <style>${BASE_CSS}${tpl.customCss}</style>
</head> </head>
<body> <body style="${bodyStyle(tpl)}">
<h1>${escapeHtml(ast.guide.title)}</h1> <div class="doc doc-simple">
${ast.guide.descriptionHtml ? `<div class="desc">${ast.guide.descriptionHtml}</div>` : ''} ${renderCover(ast, tpl)}
${toc}
${stepsHtml} ${stepsHtml}
<footer><small>Generated by StepForge on ${escapeHtml(ast.generatedAt)} ${ast.steps.length} steps</small></footer> <footer class="footer-note">Generated by StepForge on ${escapeHtml(ast.generatedAt)} · ${ast.steps.length} step${ast.steps.length === 1 ? '' : 's'}</footer>
</div>
</body> </body>
</html> </html>
`; `;
@@ -128,39 +553,19 @@ function exportHtmlRich(ast, outDir, template = {}) {
fs.mkdirSync(outDir, { recursive: true }); fs.mkdirSync(outDir, { recursive: true });
const images = tpl.includeImages ? renderAllImages(ast) : new Map(); const images = tpl.includeImages ? renderAllImages(ast) : new Map();
const storageKey = `stepforge-progress-${ast.guide.id}`; const storageKey = `stepforge-progress-${ast.guide.id}`;
const tocHtml = tpl.toc && ast.steps.length > 1
? `
<aside class="toc-panel">
<section class="toc-card">
<h2>Contents</h2>
<ul class="toc-list">
${renderRichToc(ast)}
</ul>
</section>
</aside>`
: '';
const tocHtml = ast.steps.map((step) => const stepsHtml = ast.steps.map((step) => renderStepCard(step, ast, images, tpl, { rich: true })).join('\n');
`<li class="d${step.depth}"><a href="#${anchorFor(step)}">${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}</a></li>`
).join('\n');
const stepsHtml = ast.steps.map((step) => `
<section class="step${step.skipped ? ' skipped' : ''}" id="${anchorFor(step)}">
<h2>
<label class="check"><input type="checkbox" class="step-done" data-step="${escapeHtml(step.stepId)}"></label>
${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}
</h2>
${stepBodyHtml(step, ast, images, tpl)}
</section>`).join('\n');
const richCss = `
.layout { display: flex; gap: 28px; max-width: 1180px; margin: 0 auto; }
nav.toc { position: sticky; top: 16px; align-self: flex-start; min-width: 220px; max-width: 280px;
max-height: calc(100vh - 32px); overflow-y: auto; font-size: .92em;
border: 1px solid #e5e7eb; border-radius: 8px; padding: 14px; }
nav.toc ul { list-style: none; margin: 0; padding: 0; }
nav.toc li { margin: .25em 0; }
nav.toc li.d1 { padding-left: 14px; } nav.toc li.d2 { padding-left: 28px; }
nav.toc a { color: inherit; text-decoration: none; }
nav.toc a:hover { color: ${tpl.accentColor}; }
main { flex: 1; min-width: 0; }
.progress { position: sticky; top: 0; background: inherit; padding: 8px 0; z-index: 2; }
.progress .bar { height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden; }
.progress .fill { height: 100%; width: 0; background: ${tpl.accentColor}; transition: width .2s; }
label.check { margin-right: 8px; }
section.step.done h2 { text-decoration: line-through; opacity: .6; }
@media (max-width: 900px) { .layout { flex-direction: column; } nav.toc { position: static; max-width: none; } }
@media (prefers-color-scheme: dark) { nav.toc { border-color: #374151; } .progress .bar { background: #374151; } }
`;
const script = ` const script = `
(function () { (function () {
@@ -197,19 +602,19 @@ function exportHtmlRich(ast, outDir, template = {}) {
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(ast.guide.title)}</title> <title>${escapeHtml(ast.guide.title)}</title>
<style>${BASE_CSS}${richCss}${tpl.customCss}</style> <style>${BASE_CSS}${RICH_CSS}${tpl.customCss}</style>
</head> </head>
<body> <body style="${bodyStyle(tpl)}">
<div class="layout"> <div class="doc layout-rich">
<nav class="toc"><strong>Contents</strong><ul>
${tocHtml} ${tocHtml}
</ul></nav>
<main> <main>
<h1>${escapeHtml(ast.guide.title)}</h1> ${renderCover(ast, tpl)}
${ast.guide.descriptionHtml ? `<div class="desc">${ast.guide.descriptionHtml}</div>` : ''} <div class="progress progress-card">
<div class="progress"><div class="label"></div><div class="bar"><div class="fill"></div></div></div> <div class="label"></div>
<div class="bar"><div class="fill"></div></div>
</div>
${stepsHtml} ${stepsHtml}
<footer><small>Generated by StepForge on ${escapeHtml(ast.generatedAt)} ${ast.steps.length} steps</small></footer> <footer class="footer-note">Generated by StepForge on ${escapeHtml(ast.generatedAt)} · ${ast.steps.length} step${ast.steps.length === 1 ? '' : 's'}</footer>
</main> </main>
</div> </div>
<script>${script}</script> <script>${script}</script>
+2
View File
@@ -42,6 +42,8 @@ function htmlToMarkdown(html) {
.replace(/<hr\s*\/?>/gi, '\n---\n') .replace(/<hr\s*\/?>/gi, '\n---\n')
.replace(/<p>/gi, '\n') .replace(/<p>/gi, '\n')
.replace(/<\/p>/gi, '\n') .replace(/<\/p>/gi, '\n')
.replace(/<div>/gi, '\n')
.replace(/<\/div>/gi, '\n')
.replace(/<[^>]+>/g, ''); .replace(/<[^>]+>/g, '');
return decodeEntities(out).replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); return decodeEntities(out).replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
+3 -1
View File
@@ -3,6 +3,7 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { guideSlug, writeStepImages } = require('./common'); const { guideSlug, writeStepImages } = require('./common');
const { tocEntries, guideSummary } = require('./document-layout');
const raster = require('../core/raster'); const raster = require('../core/raster');
const { decodePng, encodePng } = require('../core/png'); const { decodePng, encodePng } = require('../core/png');
@@ -39,7 +40,8 @@ function exportImageBundle(ast, outDir, template = {}) {
const meta = { const meta = {
format: 'stepforge-image-bundle', format: 'stepforge-image-bundle',
version: 1, version: 1,
guide: { title: ast.guide.title, generatedAt: ast.generatedAt }, guide: { title: ast.guide.title, generatedAt: ast.generatedAt, summary: guideSummary(ast) },
toc: tocEntries(ast).map(({ number, title, depth, anchor }) => ({ number, title, depth, anchor })),
steps: ast.steps.map((step) => ({ steps: ast.steps.map((step) => ({
number: step.number, number: step.number,
title: step.title, title: step.title,
+2
View File
@@ -2,6 +2,7 @@
const { exportJson } = require('./json'); const { exportJson } = require('./json');
const { exportMarkdown } = require('./markdown'); const { exportMarkdown } = require('./markdown');
const { exportWikiJs } = require('./wikijs');
const { exportHtmlSimple, exportHtmlRich } = require('./html'); const { exportHtmlSimple, exportHtmlRich } = require('./html');
const { exportConfluence } = require('./confluence'); const { exportConfluence } = require('./confluence');
const { exportPdf } = require('./pdf'); const { exportPdf } = require('./pdf');
@@ -14,6 +15,7 @@ const { exportPptx } = require('./pptx');
const EXPORTERS = { const EXPORTERS = {
json: exportJson, json: exportJson,
markdown: exportMarkdown, markdown: exportMarkdown,
wikijs: exportWikiJs,
'html-simple': exportHtmlSimple, 'html-simple': exportHtmlSimple,
'html-rich': exportHtmlRich, 'html-rich': exportHtmlRich,
confluence: exportConfluence, confluence: exportConfluence,
+3
View File
@@ -3,6 +3,7 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { guideSlug, writeStepImages, stepBlocks, codeBlockText } = require('./common'); const { guideSlug, writeStepImages, stepBlocks, codeBlockText } = require('./common');
const { tocEntries, guideSummary } = require('./document-layout');
/** /**
* JSON exporter: structured guide + steps, annotated screenshots written to * JSON exporter: structured guide + steps, annotated screenshots written to
@@ -29,7 +30,9 @@ function exportJson(ast, outDir, template = {}) {
descriptionHtml: ast.guide.descriptionHtml, descriptionHtml: ast.guide.descriptionHtml,
createdAt: ast.guide.createdAt, createdAt: ast.guide.createdAt,
updatedAt: ast.guide.updatedAt, updatedAt: ast.guide.updatedAt,
summary: guideSummary(ast),
}, },
toc: tocEntries(ast).map(({ number, title, depth, anchor }) => ({ number, title, depth, anchor })),
steps: ast.steps.map((step) => ({ steps: ast.steps.map((step) => ({
number: step.number, number: step.number,
kind: step.kind, kind: step.kind,
+154
View File
@@ -0,0 +1,154 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { escapeHtml } = require('../core/util');
const { guideSlug, writeStepImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
const { htmlToMarkdown } = require('./htmlmd');
const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout');
const DEFAULT_TEMPLATE = {
toc: true,
includeImages: true,
azureWiki: false,
imageMaxWidth: 0, // 0 = natural size
};
const WIKIJS_CALLOUT_CLASS = {
info: 'is-info',
success: 'is-success',
warn: 'is-warning',
error: 'is-danger',
};
const HTML_CALLOUT_THEME = {
info: { label: 'Note', border: '#2563eb', fg: '#1d4ed8', kind: 'note' },
success: { label: 'Tip', border: '#10b981', fg: '#047857', kind: 'tip' },
warn: { label: 'Warning', border: '#f59e0b', fg: '#b45309', kind: 'warning' },
error: { label: 'Important', border: '#ef4444', fg: '#b91c1c', kind: 'important' },
};
function anchorFor(step) {
return `step-${step.number.replace(/\./g, '-')}`;
}
function quoteBody(text) {
return text ? text.replace(/\n/g, '\n> ') : '';
}
function emitBlock(lines, tb, { alertStyle = 'gfm' } = {}) {
const body = htmlToMarkdown(tb.descriptionHtml);
if (alertStyle === 'html') {
const theme = HTML_CALLOUT_THEME[tb.level] || HTML_CALLOUT_THEME.info;
const label = theme.label;
const title = tb.title ? `${label}: ${tb.title}` : label;
const style = `border-left: 4px solid ${theme.border}; padding: 14px 16px; margin: 14px 0; border-radius: 0 16px 16px 0;`;
lines.push(
`<div class="sf-callout sf-callout-${theme.kind}" style="${style}">`,
`<div style="font-weight: 700; color: ${theme.fg}; margin-bottom: ${body ? '6px' : '0'};">${escapeHtml(title)}</div>`,
);
if (body) lines.push(`<div style="color: inherit;">${tb.descriptionHtml || ''}</div>`);
lines.push('</div>', '');
return;
}
if (alertStyle === 'wikijs') {
const label = tb.title || LEVEL_LABEL[tb.level] || 'Note';
const className = WIKIJS_CALLOUT_CLASS[tb.level] || 'is-info';
lines.push(`> **${label}**`);
if (body) lines.push(`> ${quoteBody(body)}`);
lines.push(`{.${className}}`, '');
return;
}
const label = LEVEL_LABEL[tb.level] || 'Note';
lines.push(`> [!${label.toUpperCase()}]`);
if (tb.title) lines.push(`> **${tb.title}**`);
if (body) lines.push(`> ${quoteBody(body)}`);
lines.push('');
}
function renderMarkdownGuide(ast, outDir, template = {}, {
defaults = DEFAULT_TEMPLATE,
alertStyle = 'gfm',
tocTitle = 'Contents',
fileExt = '.md',
} = {}) {
const tpl = { ...defaults, ...template };
fs.mkdirSync(outDir, { recursive: true });
const images = tpl.includeImages ? writeStepImages(ast, outDir) : new Map();
const lines = [];
lines.push(`# ${ast.guide.title}`, '');
lines.push('<div style="height:4px;background:#2563eb;border-radius:999px;margin:12px 0 18px;"></div>', '');
const metaLines = guideMetaLines(ast);
if (metaLines.length) lines.push(metaLines.join(' · '), '');
lines.push(`*${guideSummary(ast)}*`, '');
if (ast.guide.descriptionHtml) lines.push(htmlToMarkdown(ast.guide.descriptionHtml), '');
if (tpl.toc && ast.steps.length > 1) {
lines.push(`## ${tocTitle}`, '');
for (const entry of tocEntries(ast)) {
const indent = ' '.repeat(entry.depth);
lines.push(`${indent}- [${entry.number}. ${entry.title}](#${entry.anchor})`);
}
lines.push('');
}
for (const step of ast.steps) {
const heading = step.depth > 0 ? '###' : '##';
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
lines.push(`<a id="${anchorFor(step)}"></a>`, '');
for (const tb of beforeTitle) emitBlock(lines, tb, { alertStyle });
lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, '');
if (step.skipped) lines.push('*(skipped)*', '');
for (const tb of afterTitle) emitBlock(lines, tb, { alertStyle });
for (const tb of beforeDescription) emitBlock(lines, tb, { alertStyle });
if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), '');
for (const tb of afterDescription) emitBlock(lines, tb, { alertStyle });
for (const tb of beforeImage) emitBlock(lines, tb, { alertStyle });
const img = images.get(step.stepId);
if (img) {
if (tpl.azureWiki && tpl.imageMaxWidth > 0) {
lines.push(`![Step ${step.number}](${img.relPath} =${tpl.imageMaxWidth}x)`, '');
} else {
lines.push(`![Step ${step.number}](${img.relPath})`, '');
}
}
for (const tb of afterImage) emitBlock(lines, tb, { alertStyle });
for (const block of rest) {
if (block.kind === 'text') {
emitBlock(lines, block, { alertStyle });
} else if (block.kind === 'code') {
lines.push(`\`\`\`${block.language || ''}`, codeBlockText(block), '```', '');
} else if (block.kind === 'table') {
if (!block.rows || !block.rows.length) continue;
const width = Math.max(...block.rows.map((r) => r.length));
const pad = (r) => { const c = [...r]; while (c.length < width) c.push(''); return c; };
lines.push(`| ${pad(block.rows[0]).join(' | ')} |`);
lines.push(`|${' --- |'.repeat(width)}`);
for (const row of block.rows.slice(1)) lines.push(`| ${pad(row).join(' | ')} |`);
lines.push('');
}
}
}
const file = path.join(outDir, `${guideSlug(ast)}${fileExt}`);
fs.writeFileSync(file, lines.join('\n').replace(/\n{3,}/g, '\n\n') + '\n');
return { file, imageCount: images.size };
}
module.exports = {
DEFAULT_TEMPLATE,
anchorFor,
renderMarkdownGuide,
};
+7 -82
View File
@@ -1,94 +1,19 @@
'use strict'; 'use strict';
const fs = require('node:fs'); const { DEFAULT_TEMPLATE, anchorFor, renderMarkdownGuide } = require('./markdown-guide');
const path = require('node:path');
const { guideSlug, writeStepImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common');
const { htmlToMarkdown } = require('./htmlmd');
/** /**
* Markdown exporter. Writes <slug>.md plus a steps-<slug>/ image folder. * Markdown exporter. Writes <slug>.md plus a steps-<slug>/ image folder.
* azureWiki mode emits resized image syntax (=WxH) Azure DevOps wikis accept. * azureWiki mode emits resized image syntax (=WxH) Azure DevOps wikis accept.
*/ */
const DEFAULT_TEMPLATE = {
toc: true,
includeImages: true,
azureWiki: false,
imageMaxWidth: 0, // 0 = natural size
};
function anchorFor(step) {
return `step-${step.number.replace(/\./g, '-')}`;
}
function exportMarkdown(ast, outDir, template = {}) { function exportMarkdown(ast, outDir, template = {}) {
const tpl = { ...DEFAULT_TEMPLATE, ...template }; return renderMarkdownGuide(ast, outDir, template, {
fs.mkdirSync(outDir, { recursive: true }); defaults: DEFAULT_TEMPLATE,
const images = tpl.includeImages ? writeStepImages(ast, outDir) : new Map(); alertStyle: 'html',
const lines = []; tocTitle: 'Contents',
fileExt: '.md',
lines.push(`# ${ast.guide.title}`, ''); });
if (ast.guide.descriptionHtml) lines.push(htmlToMarkdown(ast.guide.descriptionHtml), '');
if (tpl.toc && ast.steps.length > 1) {
lines.push('## Contents', '');
for (const step of ast.steps) {
const indent = ' '.repeat(step.depth);
lines.push(`${indent}- [${step.number}. ${step.title || 'Untitled step'}](#${anchorFor(step)})`);
}
lines.push('');
}
for (const step of ast.steps) {
const heading = step.depth > 0 ? '###' : '##';
lines.push(`<a id="${anchorFor(step)}"></a>`, '');
lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, '');
if (step.skipped) lines.push('*(skipped)*', '');
emitBlocks(lines, step, 'before-description');
if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), '');
const img = images.get(step.stepId);
if (img) {
if (tpl.azureWiki && tpl.imageMaxWidth > 0) {
lines.push(`![Step ${step.number}](${img.relPath} =${tpl.imageMaxWidth}x)`, '');
} else {
lines.push(`![Step ${step.number}](${img.relPath})`, '');
}
}
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) {
if (block.kind === 'code') {
lines.push(`\`\`\`${block.language || ''}`, codeBlockText(block), '```', '');
} else if (block.kind === 'table') {
if (!block.rows || !block.rows.length) continue;
const width = Math.max(...block.rows.map((r) => r.length));
const pad = (r) => { const c = [...r]; while (c.length < width) c.push(''); return c; };
lines.push(`| ${pad(block.rows[0]).join(' | ')} |`);
lines.push(`|${' --- |'.repeat(width)}`);
for (const row of block.rows.slice(1)) lines.push(`| ${pad(row).join(' | ')} |`);
lines.push('');
}
}
emitBlocks(lines, step, 'after-description');
emitBlocks(lines, step, 'after-image');
}
const file = path.join(outDir, `${guideSlug(ast)}.md`);
fs.writeFileSync(file, lines.join('\n').replace(/\n{3,}/g, '\n\n') + '\n');
return { file, imageCount: images.size };
}
function emitBlocks(lines, step, position) {
for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) {
const label = LEVEL_LABEL[tb.level] || 'Note';
lines.push(`> **${label}${tb.title ? `: ${tb.title}` : ''}**`);
const body = htmlToMarkdown(tb.descriptionHtml);
if (body) lines.push(`> ${body.replace(/\n/g, '\n> ')}`);
lines.push('');
}
} }
module.exports = { exportMarkdown, DEFAULT_TEMPLATE, anchorFor }; module.exports = { exportMarkdown, DEFAULT_TEMPLATE, anchorFor };
+287 -32
View File
@@ -3,8 +3,100 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { PdfBuilder } = require('../core/pdf'); const { PdfBuilder } = require('../core/pdf');
const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common'); const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
const { htmlToText } = require('../core/util'); const { htmlToText } = require('../core/util');
const { htmlToBlocks } = require('../core/htmlblocks');
const LIST_INDENT = 14;
const QUOTE_INDENT = 10;
const HEADING_BUMP = { h1: 2.5, h2: 2, h3: 1.5, h4: 1 };
// Callout styling per text-block level, matching the colors used in the
// HTML/editor UI so a "Tip" looks distinct from a "Warning" at a glance.
const LEVEL_STYLE = {
info: { accent: [59, 130, 246], tint: [239, 246, 255] }, // blue — Note
success: { accent: [16, 185, 129], tint: [236, 253, 245] }, // green — Tip
warn: { accent: [245, 158, 11], tint: [255, 251, 235] }, // amber — Warning
error: { accent: [239, 68, 68], tint: [254, 242, 242] }, // red — Important
};
function fontForRun(run) {
if (run.code) return 'F3';
if (run.bold && run.italic) return 'F5';
if (run.bold) return 'F2';
if (run.italic) return 'F4';
return 'F1';
}
/** Split formatted runs into words (font/link tagged) and greedily wrap to maxWidth. */
function wrapRuns(pdf, runs, size, maxWidth) {
const words = [];
for (const run of runs) {
const font = fontForRun(run);
for (const part of run.text.split(/(\s+)/)) {
if (part === '') continue;
words.push({ text: part, font, href: run.href });
}
}
const lines = [];
let line = [];
let w = 0;
for (const word of words) {
const isSpace = /^\s+$/.test(word.text);
const ww = pdf.textWidth(word.text, size, word.font);
if (!isSpace && line.length && w + ww > maxWidth) {
lines.push(line);
line = [];
w = 0;
}
if (isSpace && !line.length) continue;
line.push(word);
w += ww;
}
if (line.length) lines.push(line);
return lines;
}
/**
* Lay out description HTML into render-ready items, preserving bold,
* italic, links, lists, blockquotes and headings.
* Returns { items, height }; items: { kind: 'hr', width } | { kind: 'text',
* lines, size, lineHeight, indent, prefix, muted }.
*/
function layoutDescription(pdf, html, maxWidth, baseSize) {
const items = [];
let height = 0;
for (const block of htmlToBlocks(html || '')) {
if (block.type === 'hr') {
items.push({ kind: 'hr', width: maxWidth });
height += 12;
continue;
}
let size = baseSize;
let runs = block.runs;
let indent = (block.indent || 0) * LIST_INDENT;
let prefix = null;
let muted = false;
if (HEADING_BUMP[block.type]) {
size = baseSize + HEADING_BUMP[block.type];
runs = runs.map((r) => ({ ...r, bold: true }));
} else if (block.type === 'li') {
prefix = '•';
indent += LIST_INDENT;
} else if (block.type === 'oli') {
prefix = `${block.n}.`;
indent += LIST_INDENT;
} else if (block.type === 'blockquote') {
indent += QUOTE_INDENT;
muted = true;
}
const lines = wrapRuns(pdf, runs, size, maxWidth - indent);
const lineHeight = size * 1.35;
items.push({ kind: 'text', lines, size, lineHeight, indent, prefix, muted });
height += lines.length * lineHeight + 4;
}
return { items, height };
}
/** /**
* PDF exporter: cover block, optional TOC, one section per step with the * PDF exporter: cover block, optional TOC, one section per step with the
@@ -38,8 +130,11 @@ function exportPdf(ast, outDir, template = {}) {
const images = tpl.includeImages ? renderAllImages(ast) : new Map(); const images = tpl.includeImages ? renderAllImages(ast) : new Map();
let y = M; let y = M;
// Never start a page break when already at the top of a page — an item
// taller than a full page (e.g. a step's combined head height) must
// still render and overflow naturally rather than push out a blank page.
const ensure = (needed) => { const ensure = (needed) => {
if (y + needed > size.height - M) { if (y > M && y + needed > size.height - M) {
pdf.addPage(); pdf.addPage();
y = M; y = M;
} }
@@ -51,47 +146,188 @@ function exportPdf(ast, outDir, template = {}) {
y += fs_ * leading; y += fs_ * leading;
} }
}; };
/** Render laid-out description items, preserving bold/italic/links/lists. */
const writeDescription = (html, { size: baseSize = 10.5, color = [0, 0, 0], indent: indentBase = 0 } = {}) => {
const { items } = layoutDescription(pdf, html, usableW - indentBase, baseSize);
for (const item of items) {
if (item.kind === 'hr') {
ensure(12);
pdf.rect(M + indentBase, y + 5, item.width, 0.8, { fill: [225, 228, 232] });
y += 12;
continue;
}
item.lines.forEach((line, idx) => {
ensure(item.lineHeight);
const textX = M + indentBase + item.indent;
if (idx === 0 && item.prefix) pdf.text(item.prefix, textX - LIST_INDENT, y, { size: item.size, font: 'F1', color });
const parts = line.map((word) => ({
text: word.text,
font: word.font,
color: word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : color),
}));
pdf.textRun(parts, textX, y, item.size);
y += item.lineHeight;
});
y += 4;
}
};
/** Height a callout block (emitBlock) will occupy, including its trailing gap. */
const measureBlock = (tb) => {
const { height: bodyH } = tb.descriptionHtml
? layoutDescription(pdf, tb.descriptionHtml, usableW - 18, 9.5)
: { height: 0 };
return 16 + bodyH + 6;
};
/** Height an image will occupy on the page, including its trailing gap. */
const measureImage = (stepId) => {
const img = images.get(stepId);
if (!img) return 0;
const maxH = usableH * tpl.imageMaxHeightRatio;
let h = (img.height / img.width) * usableW;
if (h > maxH) h = maxH;
return h + 10;
};
const measureCode = (block) => {
const lines = String(codeBlockText(block) || '').split('\n');
const lineH = 9 * 1.3;
return lines.length * lineH + 16;
};
const measureTable = (block) => {
if (!block.rows || !block.rows.length) return 0;
return block.rows.length * 16 + 8;
};
/**
* Compute the vertical space a step will need: `head` is the title, the
* accent rule, positioned text blocks, the description, and the image
* kept together on one page and `total` adds the remaining blocks plus
* the trailing gap, used to decide whether the whole step fits on a
* fresh page.
*/
const measureStep = (step) => {
const headSize = step.depth > 0 ? 12 : 14;
const titleText = `${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`;
const titleLines = pdf.wrapText(titleText, headSize, usableW, 'F2');
let head = Math.max(40, titleLines.length * headSize * 1.35 + 8);
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
for (const tb of [...beforeTitle, ...afterTitle, ...beforeDescription, ...afterDescription, ...beforeImage, ...afterImage]) {
head += measureBlock(tb);
}
if (step.descriptionHtml) head += layoutDescription(pdf, step.descriptionHtml, usableW, 10.5).height;
head += measureImage(step.stepId);
let restHeight = 0;
for (const block of rest) {
if (block.kind === 'text') restHeight += measureBlock(block);
else if (block.kind === 'code') restHeight += measureCode(block);
else if (block.kind === 'table') restHeight += measureTable(block);
}
return { head, total: head + restHeight + 10 };
};
pdf.addPage(); pdf.addPage();
if (tpl.includeCover) { if (tpl.includeCover) {
y = M + usableH * 0.18; // Keep the cover title near the top edge instead of vertically centering it.
pdf.rect(M, y - 18, usableW, 3, { fill: tpl.accentColor }); y = M;
y += 6; writeLines(ast.guide.title, { size: 28, font: 'F2' });
writeLines(ast.guide.title, { size: 26, font: 'F2' }); y += 10;
y += 8; pdf.rect(M, y, usableW, 3, { fill: tpl.accentColor });
if (ast.guide.descriptionText) writeLines(ast.guide.descriptionText, { size: 12, color: [70, 70, 70] }); y += 16;
const meta = ast.guide.metadata || {};
const metaLines = [
meta.author && `Author: ${meta.author}`,
meta.coAuthors && `Co-authors: ${meta.coAuthors}`,
meta.organization && `Organization: ${meta.organization}`,
].filter(Boolean);
for (const line of metaLines) writeLines(line, { size: 11, color: [90, 90, 90] });
if (metaLines.length) y += 8;
if (ast.guide.descriptionHtml) writeDescription(ast.guide.descriptionHtml, { size: 12, color: [70, 70, 70] });
y += 14; y += 14;
writeLines(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 10, color: [120, 120, 120] }); writeLines(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 10, color: [120, 120, 120] });
pdf.addPage(); pdf.addPage();
y = M; y = M;
} }
// Filled in below as each step claims its page, so the Contents entries
// above (already laid out before pagination starts) can link to it.
const tocTargets = new Map(); // stepId -> { pageIndex }
if (tpl.includeToc && ast.steps.length > 1) { if (tpl.includeToc && ast.steps.length > 1) {
writeLines('Contents', { size: 16, font: 'F2' }); writeLines('Contents', { size: 16, font: 'F2' });
y += 4; y += 4;
const tocSize = 10.5;
const lineH = tocSize * 1.35;
for (const step of ast.steps) { for (const step of ast.steps) {
writeLines(`${step.number}. ${step.title || 'Untitled step'}`, { const indent = 14 * step.depth;
size: 10.5, indent: 14 * step.depth, const target = {};
}); tocTargets.set(step.stepId, target);
for (const line of pdf.wrapText(`${step.number}. ${step.title || 'Untitled step'}`, tocSize, usableW - indent, 'F1')) {
ensure(lineH);
pdf.text(line, M + indent, y, { size: tocSize, color: tpl.accentColor });
pdf.linkRect(M + indent, y, usableW - indent, lineH, target);
y += lineH;
}
} }
pdf.addPage(); pdf.addPage();
y = M; y = M;
} }
let first = true; let first = true;
let forcedFresh = false;
const pageBottom = size.height - M;
for (const step of ast.steps) { for (const step of ast.steps) {
if (step.forceNewPage && !first) { pdf.addPage(); y = M; } const { head: headHeight, total: totalHeight } = measureStep(step);
// Start a fresh page when: the step's "New page" toggle is set; the
// previous step overflowed past one page (so this step shouldn't share
// the spillover page); or this step doesn't fit in what's left of the
// current page. The last check is skipped when already at the top of a
// page, so an overlong step doesn't push out a blank page first.
const needsFreshPage = !first && (
step.forceNewPage || forcedFresh || (y > M && y + totalHeight > pageBottom)
);
if (needsFreshPage) { pdf.addPage(); y = M; }
first = false; first = false;
ensure(40); // Keep the title, accent rule, lead-in blocks, description, and image
// together — never split across a page boundary.
ensure(headHeight);
pdf.bookmark(`${step.number}. ${step.title || 'Untitled step'}`); pdf.bookmark(`${step.number}. ${step.title || 'Untitled step'}`);
const tocTarget = tocTargets.get(step.stepId);
if (tocTarget) tocTarget.pageIndex = pdf.pages.length - 1;
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
for (const tb of beforeTitle) emitBlock(tb);
const headSize = step.depth > 0 ? 12 : 14; const headSize = step.depth > 0 ? 12 : 14;
writeLines(`${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`, { size: headSize, font: 'F2' }); writeLines(`${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`, { size: headSize, font: 'F2' });
pdf.rect(M, y, usableW, 0.8, { fill: [225, 228, 232] }); pdf.rect(M, y, usableW, 0.8, { fill: [225, 228, 232] });
y += 8; y += 8;
emitBlocks(step, 'before-description'); for (const tb of afterTitle) emitBlock(tb);
if (step.descriptionText) { writeLines(step.descriptionText); y += 4; } for (const tb of beforeDescription) emitBlock(tb);
if (step.descriptionHtml) writeDescription(step.descriptionHtml);
for (const tb of afterDescription) emitBlock(tb);
for (const tb of beforeImage) emitBlock(tb);
const img = images.get(step.stepId); const img = images.get(step.stepId);
if (img) { if (img) {
@@ -103,9 +339,12 @@ function exportPdf(ast, outDir, template = {}) {
pdf.image(img, M, y, w, h); pdf.image(img, M, y, w, h);
y += h + 10; y += h + 10;
} }
for (const tb of afterImage) emitBlock(tb);
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) { for (const block of rest) {
if (block.kind === 'code') { if (block.kind === 'text') {
emitBlock(block);
} else if (block.kind === 'code') {
const lines = String(codeBlockText(block) || '').split('\n'); const lines = String(codeBlockText(block) || '').split('\n');
const lineH = 9 * 1.3; const lineH = 9 * 1.3;
ensure(Math.min(lines.length, 4) * lineH + 12); ensure(Math.min(lines.length, 4) * lineH + 12);
@@ -138,26 +377,42 @@ function exportPdf(ast, outDir, template = {}) {
} }
} }
emitBlocks(step, 'after-description');
emitBlocks(step, 'after-image');
y += 10; y += 10;
forcedFresh = totalHeight > usableH;
} }
function emitBlocks(step, position) { function emitBlock(tb) {
for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) { const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; const { items, height: bodyH } = tb.descriptionHtml
const bodyLines = tb.descriptionText ? pdf.wrapText(tb.descriptionText, 9.5, usableW - 18) : []; ? layoutDescription(pdf, tb.descriptionHtml, usableW - 18, 9.5)
const blockH = 16 + bodyLines.length * 13; : { items: [], height: 0 };
ensure(blockH + 4); const blockH = 16 + bodyH;
pdf.rect(M, y, 3, blockH, { fill: tpl.accentColor }); const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info;
pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2' }); ensure(blockH + 4);
let by = y + 16; pdf.rect(M, y, usableW, blockH, { fill: style.tint });
for (const line of bodyLines) { pdf.rect(M, y, 3, blockH, { fill: style.accent });
pdf.text(line, M + 10, by, { size: 9.5 }); pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2', color: style.accent });
by += 13; let by = y + 16;
for (const item of items) {
if (item.kind === 'hr') {
pdf.rect(M + 10, by + 5, item.width, 0.8, { fill: [225, 228, 232] });
by += 12;
continue;
} }
y += blockH + 6; item.lines.forEach((line, idx) => {
const textX = M + 10 + item.indent;
if (idx === 0 && item.prefix) pdf.text(item.prefix, textX - LIST_INDENT, by, { size: item.size, font: 'F1' });
const parts = line.map((word) => ({
text: word.text,
font: word.font,
color: word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : [0, 0, 0]),
}));
pdf.textRun(parts, textX, by, item.size);
by += item.lineHeight;
});
by += 4;
} }
y += blockH + 6;
} }
fs.mkdirSync(outDir, { recursive: true }); fs.mkdirSync(outDir, { recursive: true });
+185 -16
View File
@@ -5,21 +5,41 @@ const path = require('node:path');
const { zipSync } = require('../core/zip'); const { zipSync } = require('../core/zip');
const { escapeXml } = require('../core/util'); const { escapeXml } = require('../core/util');
const { encodePng } = require('../core/png'); const { encodePng } = require('../core/png');
const { guideSlug, renderAllImages } = require('./common'); const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups } = require('./common');
const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout');
/** /**
* PPTX exporter: a title slide plus one 16:9 slide per step (title bar + * PPTX exporter: a title slide plus one 16:9 slide per step, with
* screenshot + description). PresentationML written directly. * positioned text blocks laid out around the step title, description, and
* screenshot. PresentationML written directly.
*/ */
const DEFAULT_TEMPLATE = { const DEFAULT_TEMPLATE = {
includeImages: true, includeImages: true,
titleSlide: true, titleSlide: true,
includeToc: true,
}; };
const SLIDE_W = 12192000; // EMU, 16:9 const SLIDE_W = 12192000; // EMU, 16:9
const SLIDE_H = 6858000; const SLIDE_H = 6858000;
const EMU_PER_PX = 9525; const EMU_PER_PX = 9525;
const SLIDE_MARGIN = 914400;
const TITLE_Y = 420000;
const TITLE_H = 620000;
const TITLE_RULE_Y = 1120000;
const CONTENT_Y = 1500000;
const CONTENT_FOOTER_Y = 720000;
const CALL_OUT_HEIGHT = 620000;
const CALL_OUT_GAP = 90000;
const CALL_OUT_BAR_W = 24000;
const TOC_ENTRY_START_Y = 2300000;
const TOC_ENTRY_SPACING = 255000;
const TOC_ENTRY_HEIGHT = 220000;
const TOC_BOTTOM_MARGIN = 500000;
const TOC_MAX_ENTRIES_PER_SLIDE = Math.max(
1,
Math.floor((SLIDE_H - TOC_BOTTOM_MARGIN - TOC_ENTRY_START_Y - TOC_ENTRY_HEIGHT) / TOC_ENTRY_SPACING) + 1,
);
let shapeIdCounter = 10; // reset per export for deterministic output let shapeIdCounter = 10; // reset per export for deterministic output
@@ -29,6 +49,12 @@ function textBox(x, y, w, h, runsXml) {
`<p:txBody><a:bodyPr wrap="square"><a:normAutofit/></a:bodyPr><a:lstStyle/>${runsXml}</p:txBody></p:sp>`; `<p:txBody><a:bodyPr wrap="square"><a:normAutofit/></a:bodyPr><a:lstStyle/>${runsXml}</p:txBody></p:sp>`;
} }
function rectShape(x, y, w, h, fill) {
return `<p:sp><p:nvSpPr><p:cNvPr id="${shapeIdCounter++}" name="Rect"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>` +
`<p:spPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${w}" cy="${h}"/></a:xfrm>` +
`<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:srgbClr val="${fill}"/></a:solidFill><a:ln><a:noFill/></a:ln></p:spPr></p:sp>`;
}
function para(text, { size = 1800, bold = false, color = '111827' } = {}) { function para(text, { size = 1800, bold = false, color = '111827' } = {}) {
return `<a:p><a:r><a:rPr lang="en-US" sz="${size}" b="${bold ? 1 : 0}" dirty="0"><a:solidFill><a:srgbClr val="${color}"/></a:solidFill></a:rPr><a:t>${escapeXml(text)}</a:t></a:r></a:p>`; return `<a:p><a:r><a:rPr lang="en-US" sz="${size}" b="${bold ? 1 : 0}" dirty="0"><a:solidFill><a:srgbClr val="${color}"/></a:solidFill></a:rPr><a:t>${escapeXml(text)}</a:t></a:r></a:p>`;
} }
@@ -39,6 +65,49 @@ function picture(relId, x, y, w, h) {
`<p:spPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${w}" cy="${h}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`; `<p:spPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${w}" cy="${h}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`;
} }
const CALLOUT_STYLE = {
info: { fill: 'EFF6FF', accent: '2563EB', label: 'Note', color: '1D4ED8' },
success: { fill: 'ECFDF5', accent: '10B981', label: 'Tip', color: '047857' },
warn: { fill: 'FFFBEB', accent: 'F59E0B', label: 'Warning', color: 'B45309' },
error: { fill: 'FEF2F2', accent: 'EF4444', label: 'Important', color: 'B91C1C' },
};
function estimateWrappedLines(text, charsPerLine) {
const raw = String(text || '').trim();
if (!raw) return 0;
return raw.split(/\n+/).reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / charsPerLine)), 0);
}
function calloutHeight(tb) {
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
const lines = estimateWrappedLines(label, 46) + estimateWrappedLines(tb.descriptionText, 72);
return Math.max(CALL_OUT_HEIGHT, 360000 + (lines * 150000));
}
function calloutXml(tb, x, y, w) {
const style = CALLOUT_STYLE[tb.level] || CALLOUT_STYLE.info;
const height = calloutHeight(tb);
const label = `${style.label}${tb.title ? `: ${tb.title}` : ''}`;
const titlePara = para(label, { size: 1400, bold: true, color: style.color });
const bodyPara = tb.descriptionText ? para(tb.descriptionText.slice(0, 400), { size: 1250, color: '374151' }) : '';
const innerX = x + CALL_OUT_BAR_W + 24000;
const innerW = Math.max(0, w - CALL_OUT_BAR_W - 72000);
return {
height,
xml: [
rectShape(x, y, w, height, style.fill),
rectShape(x, y, CALL_OUT_BAR_W, height, style.accent),
textBox(innerX, y + 12000, innerW, Math.max(0, height - 24000), `${titlePara}${bodyPara}`),
].join(''),
};
}
function descriptionHeight(text) {
const lines = estimateWrappedLines(text, 95);
if (!lines) return 0;
return Math.max(360000, 260000 + (lines * 130000));
}
function slideXml(content) { function slideXml(content) {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" <p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
@@ -50,6 +119,33 @@ ${content}
</p:spTree></p:cSld><p:clrMapOvr><a:overrideClrMapping bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:clrMapOvr></p:sld>`; </p:spTree></p:cSld><p:clrMapOvr><a:overrideClrMapping bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:clrMapOvr></p:sld>`;
} }
function chunkArray(items, size) {
const chunks = [];
for (let i = 0; i < items.length; i += size) {
chunks.push(items.slice(i, i + size));
}
return chunks;
}
function tocSlideXml(ast, entries, { continued = false } = {}) {
let tocContent = rectShape(0, 0, SLIDE_W, 18000, '2563EB');
tocContent += textBox(914400, 760000, SLIDE_W - 1828800, 700000,
para(continued ? 'Contents (continued)' : 'Contents', { size: 3000, bold: true }));
tocContent += rectShape(914400, 1500000, 1600000, 14000, '2563EB');
tocContent += textBox(914400, 1680000, SLIDE_W - 1828800, 450000,
para(guideSummary(ast), { size: 1500, color: '6B7280' }));
entries.forEach((entry, index) => {
const x = 914400 + (entry.depth * 220000);
const y = TOC_ENTRY_START_Y + (index * TOC_ENTRY_SPACING);
tocContent += rectShape(x, y + 78000, 24000, 90000, '2563EB');
tocContent += textBox(x + 48000, y, SLIDE_W - x - 1200000, TOC_ENTRY_HEIGHT,
para(`${entry.number}. ${entry.title}`, { size: entry.depth === 0 ? 1550 : 1450, bold: entry.depth === 0 }));
});
return slideXml(tocContent);
}
const THEME_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> const THEME_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="StepForge"> <a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="StepForge">
<a:themeElements> <a:themeElements>
@@ -85,22 +181,91 @@ function exportPptx(ast, outDir, template = {}) {
const images = tpl.includeImages ? renderAllImages(ast) : new Map(); const images = tpl.includeImages ? renderAllImages(ast) : new Map();
const slides = []; // { xml, rels: [{id, target}], media: [{name, data}] } const slides = []; // { xml, rels: [{id, target}], media: [{name, data}] }
const toc = tpl.includeToc && ast.steps.length > 1 ? tocEntries(ast) : [];
if (tpl.titleSlide) { if (tpl.titleSlide) {
const metaLines = guideMetaLines(ast);
let titleContent = rectShape(0, 0, SLIDE_W, 18000, '2563EB');
titleContent += textBox(SLIDE_MARGIN, 2050000, SLIDE_W - 1828800, 1200000, para(ast.guide.title, { size: 4000, bold: true }));
titleContent += rectShape(SLIDE_MARGIN, 3300000, 2200000, 14000, '2563EB');
titleContent += textBox(SLIDE_MARGIN, 3500000, SLIDE_W - 1828800, 1100000,
[para(guideSummary(ast), { size: 1800, color: '6B7280' }),
...metaLines.map((line) => para(line, { size: 1500, color: '6B7280' }))].join(''));
slides.push({ slides.push({
xml: slideXml( xml: slideXml(titleContent),
textBox(914400, 2300000, SLIDE_W - 1828800, 1200000, para(ast.guide.title, { size: 4000, bold: true })) +
textBox(914400, 3600000, SLIDE_W - 1828800, 800000,
para(`${ast.steps.length} steps — ${ast.generatedAt.slice(0, 10)}`, { size: 1800, color: '6B7280' }))
),
rels: [], media: [], rels: [], media: [],
}); });
} }
if (toc.length) {
const tocPages = chunkArray(toc, TOC_MAX_ENTRIES_PER_SLIDE);
tocPages.forEach((page, index) => {
slides.push({
xml: tocSlideXml(ast, page, { continued: index > 0 }),
rels: [], media: [],
});
});
}
let mediaCounter = 0; let mediaCounter = 0;
for (const step of ast.steps) { for (const step of ast.steps) {
let content = textBox(457200, 274638, SLIDE_W - 914400, 700000, const {
para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2400, bold: true })); beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
} = stepContentGroups(step);
let content = rectShape(0, 0, SLIDE_W, 18000, '2563EB');
const beforeTitleReserve = beforeTitle.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0);
const titleY = beforeTitle.length ? 220000 + beforeTitleReserve + 120000 : TITLE_Y;
const titleRuleY = beforeTitle.length ? titleY + TITLE_H + 120000 : TITLE_RULE_Y;
const contentY = beforeTitle.length ? Math.max(CONTENT_Y, titleRuleY + 180000) : CONTENT_Y;
const beforeTitleY = beforeTitle.length ? 220000 : CONTENT_Y;
let y = beforeTitleY;
for (const tb of beforeTitle) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
}
content += textBox(457200, titleY, SLIDE_W - 914400, TITLE_H,
para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2600, bold: true }));
content += rectShape(457200, titleRuleY, 2400000, 12000, '2563EB');
y = contentY;
for (const tb of afterTitle) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
}
for (const tb of beforeDescription) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
}
if (step.descriptionText) {
const descH = descriptionHeight(step.descriptionText);
content += textBox(457200, y, SLIDE_W - 914400, Math.max(360000, descH || 420000),
para(step.descriptionText.slice(0, 300), { size: 1400, color: '374151' }));
y += Math.max(360000, descH || 420000) + 90000;
}
for (const tb of afterDescription) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
}
for (const tb of beforeImage) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
}
const postImageReserve = afterImage.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0);
const rels = []; const rels = [];
const media = []; const media = [];
@@ -111,16 +276,20 @@ function exportPptx(ast, outDir, template = {}) {
media.push({ name, data: encodePng(img) }); media.push({ name, data: encodePng(img) });
const relId = 2; // rId1 = layout, rId2 = image const relId = 2; // rId1 = layout, rId2 = image
rels.push({ id: relId, name }); rels.push({ id: relId, name });
// Fit image into a centered region below the title. // Fit image into the remaining centered region before the trailing blocks.
const maxW = SLIDE_W - 1219200, maxH = SLIDE_H - 1554638 - (step.descriptionText ? 700000 : 200000); const maxW = SLIDE_W - 1219200;
const maxH = Math.max(0, SLIDE_H - y - postImageReserve - 100000);
let w = img.width * EMU_PER_PX, h = img.height * EMU_PER_PX; let w = img.width * EMU_PER_PX, h = img.height * EMU_PER_PX;
const scale = Math.min(maxW / w, maxH / h, 1); const scale = Math.min(maxW / w, maxH / h, 1);
w = Math.round(w * scale); h = Math.round(h * scale); w = Math.round(w * scale); h = Math.round(h * scale);
content += picture(relId, Math.round((SLIDE_W - w) / 2), 1054638, w, h); content += picture(relId, Math.round((SLIDE_W - w) / 2), y, w, h);
y += h + 100000;
} }
if (step.descriptionText) {
content += textBox(457200, SLIDE_H - 850000, SLIDE_W - 914400, 700000, for (const tb of afterImage) {
para(step.descriptionText.slice(0, 300), { size: 1400, color: '374151' })); const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
} }
slides.push({ xml: slideXml(content), rels, media }); slides.push({ xml: slideXml(content), rels, media });
} }
+25
View File
@@ -0,0 +1,25 @@
'use strict';
const { DEFAULT_TEMPLATE, renderMarkdownGuide } = require('./markdown-guide');
/**
* Wiki.js markdown exporter. Same step/body structure as the generic
* Markdown exporter, but uses Wiki.js-friendly callout blocks.
*/
const WIKIJS_TEMPLATE = {
toc: true,
includeImages: true,
imageMaxWidth: 0,
};
function exportWikiJs(ast, outDir, template = {}) {
return renderMarkdownGuide(ast, outDir, template, {
defaults: WIKIJS_TEMPLATE,
alertStyle: 'wikijs',
tocTitle: 'Contents',
fileExt: '.md',
});
}
module.exports = { exportWikiJs, DEFAULT_TEMPLATE: WIKIJS_TEMPLATE };
+124
View File
@@ -8,6 +8,10 @@
"name": "stepforge", "name": "stepforge",
"version": "0.1.0", "version": "0.1.0",
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": {
"@tesseract.js-data/eng": "^1.0.0",
"tesseract.js": "^7.0.0"
},
"devDependencies": { "devDependencies": {
"electron": "^41.7.1", "electron": "^41.7.1",
"electron-builder": "^26.15.2" "electron-builder": "^26.15.2"
@@ -624,6 +628,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@tesseract.js-data/eng": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@tesseract.js-data/eng/-/eng-1.0.0.tgz",
"integrity": "sha512-mbTumm6KQPUHyzTPQaF3ObXYnx0SqqfV2nabqFVQBwD6Kl7PhGSLSzOlfFTWy0P3BjghaSKA2W9GB19Jk+ZcTg==",
"license": "MIT"
},
"node_modules/@types/cacheable-request": { "node_modules/@types/cacheable-request": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
@@ -1057,6 +1067,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/bmp-js": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
"license": "MIT"
},
"node_modules/boolean": { "node_modules/boolean": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
@@ -2512,6 +2528,12 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/idb-keyval": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.5.tgz",
"integrity": "sha512-eKQkTnS0relYsSOYomx8ozIbmdsQCKUdhyuIaQ2DZgKuaxtyQQMkyD/wlnQN32pO3yutN1b1L8uqwcDKaJd7/Q==",
"license": "Apache-2.0"
},
"node_modules/inflight": { "node_modules/inflight": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -2541,6 +2563,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/isarray": { "node_modules/isarray": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@ -2903,6 +2931,26 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-gyp": { "node_modules/node-gyp": {
"version": "12.4.0", "version": "12.4.0",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
@@ -3024,6 +3072,15 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"license": "MIT",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/p-cancelable": { "node_modules/p-cancelable": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
@@ -3314,6 +3371,12 @@
"util-deprecate": "~1.0.1" "util-deprecate": "~1.0.1"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -3728,6 +3791,30 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/tesseract.js": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"bmp-js": "^0.1.0",
"idb-keyval": "^6.2.0",
"is-url": "^1.2.4",
"node-fetch": "^2.6.9",
"opencollective-postinstall": "^2.0.3",
"regenerator-runtime": "^0.13.3",
"tesseract.js-core": "^7.0.0",
"wasm-feature-detect": "^1.8.0",
"zlibjs": "^0.3.1"
}
},
"node_modules/tesseract.js-core": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
"license": "Apache-2.0"
},
"node_modules/tiny-async-pool": { "node_modules/tiny-async-pool": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
@@ -3785,6 +3872,12 @@
"tmp": "^0.2.0" "tmp": "^0.2.0"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/truncate-utf8-bytes": { "node_modules/truncate-utf8-bytes": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
@@ -3909,6 +4002,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/wasm-feature-detect": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
"license": "Apache-2.0"
},
"node_modules/webcrypto-core": { "node_modules/webcrypto-core": {
"version": "1.9.2", "version": "1.9.2",
"resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz",
@@ -3923,6 +4022,22 @@
"tslib": "^2.8.1" "tslib": "^2.8.1"
} }
}, },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
@@ -4043,6 +4158,15 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
"license": "MIT",
"engines": {
"node": "*"
}
} }
} }
} }
+4
View File
@@ -15,6 +15,10 @@
"verify": "bash scripts/verify.sh", "verify": "bash scripts/verify.sh",
"bootstrap": "bash scripts/bootstrap-offline.sh" "bootstrap": "bash scripts/bootstrap-offline.sh"
}, },
"dependencies": {
"@tesseract.js-data/eng": "^1.0.0",
"tesseract.js": "^7.0.0"
},
"devDependencies": { "devDependencies": {
"electron": "^41.7.1", "electron": "^41.7.1",
"electron-builder": "^26.15.2" "electron-builder": "^26.15.2"
+2 -2
View File
@@ -97,7 +97,7 @@ Host: ${process.platform} ${process.arch} (node ${process.version})
- Portable tarball: ${files.find((f) => f.path.endsWith('.tar.gz'))?.path || 'not generated'} - Portable tarball: ${files.find((f) => f.path.endsWith('.tar.gz'))?.path || 'not generated'}
- Debian package: ${files.find((f) => f.path.endsWith('.deb'))?.path || 'not generated'} - Debian package: ${files.find((f) => f.path.endsWith('.deb'))?.path || 'not generated'}
- Sample guide archive: ${files.find((f) => f.path.endsWith('sample-guide.sfgz'))?.path || 'not generated'} - Sample guide archive: ${files.find((f) => f.path.endsWith('sample-guide.sfgz'))?.path || 'not generated'}
- Sample exports (9 formats): see examples/sample-exports/ - Sample exports (10 formats): see examples/sample-exports/
- Full artifact list with sha256 checksums: artifacts_manifest.json - Full artifact list with sha256 checksums: artifacts_manifest.json
## Packaging tool availability ## Packaging tool availability
@@ -109,7 +109,7 @@ ${toolRows}
Fallback policy: when a packaging tool is missing the build still produces Fallback policy: when a packaging tool is missing the build still produces
the runnable app (portable tarball with launcher) plus whatever package the runnable app (portable tarball with launcher) plus whatever package
formats the available tools allow. Windows artifacts are produced by formats the available tools allow. Windows artifacts are produced by
\`npm run package:windows\` (electron-builder, portable .exe); .msi/.rpm/ \`npm run package:windows\` (electron-builder, installer .exe); .msi/.rpm/
AppImage require the tools listed above and are skipped on this host. AppImage require the tools listed above and are skipped on this host.
## Offline guarantee ## Offline guarantee
+131 -40
View File
@@ -10,6 +10,11 @@ const ELECTRON_SKIP_ENV_KEYS = [
'NPM_CONFIG_ELECTRON_SKIP_BINARY_DOWNLOAD', 'NPM_CONFIG_ELECTRON_SKIP_BINARY_DOWNLOAD',
]; ];
const NPM_IGNORE_SCRIPTS_ENV_KEYS = [
'npm_config_ignore_scripts',
'NPM_CONFIG_IGNORE_SCRIPTS',
];
function resolveElectronPackageRoot() { function resolveElectronPackageRoot() {
try { try {
return path.dirname(require.resolve('electron/package.json')); return path.dirname(require.resolve('electron/package.json'));
@@ -48,6 +53,9 @@ function sanitizeElectronEnv(baseEnv = process.env) {
for (const key of ELECTRON_SKIP_ENV_KEYS) { for (const key of ELECTRON_SKIP_ENV_KEYS) {
delete env[key]; delete env[key];
} }
for (const key of NPM_IGNORE_SCRIPTS_ENV_KEYS) {
delete env[key];
}
return env; return env;
} }
@@ -67,8 +75,10 @@ function electronBinaryCandidates({ packageRoot, distDir, platform }) {
return candidatePaths; return candidatePaths;
} }
function runNpmRebuild({ function runNpmCommand({
packageRoot, packageRoot,
npmArgs,
errorLabel,
npmExecPath = process.env.npm_execpath || null, npmExecPath = process.env.npm_execpath || null,
npmNodeExecPath = process.env.npm_node_execpath || process.execPath, npmNodeExecPath = process.env.npm_node_execpath || process.execPath,
}) { }) {
@@ -76,31 +86,63 @@ function runNpmRebuild({
return false; return false;
} }
const result = spawnSync( const result = spawnSync(npmNodeExecPath, [npmExecPath, ...npmArgs], {
npmNodeExecPath, cwd: packageRoot,
[npmExecPath, 'rebuild', 'electron', '--force', '--foreground-scripts'], env: sanitizeElectronEnv(),
{ stdio: 'inherit',
cwd: packageRoot, });
env: sanitizeElectronEnv(),
stdio: 'inherit',
}
);
if (result.error) { if (result.error) {
throw result.error; throw result.error;
} }
if (result.signal) { if (result.signal) {
throw new Error(`Electron repair was interrupted by ${result.signal}`); throw new Error(`${errorLabel} was interrupted by ${result.signal}`);
} }
if (result.status !== 0) { if (result.status !== 0) {
throw new Error(`Electron rebuild failed with exit code ${result.status ?? 1}`); throw new Error(`${errorLabel} failed with exit code ${result.status ?? 1}`);
} }
return true; return true;
} }
function runNpmRebuild({
packageRoot,
npmExecPath = process.env.npm_execpath || null,
npmNodeExecPath = process.env.npm_node_execpath || process.execPath,
}) {
return runNpmCommand({
packageRoot,
npmArgs: ['rebuild', 'electron', '--force', '--foreground-scripts'],
errorLabel: 'Electron rebuild',
npmExecPath,
npmNodeExecPath,
});
}
function runNpmInstall({
packageRoot,
npmExecPath = process.env.npm_execpath || null,
npmNodeExecPath = process.env.npm_node_execpath || process.execPath,
}) {
return runNpmCommand({
packageRoot,
npmArgs: [
'install',
'--include=dev',
'--ignore-scripts=false',
'--foreground-scripts',
'--no-audit',
'--no-fund',
'--package-lock=false',
],
errorLabel: 'Electron dependency install',
npmExecPath,
npmNodeExecPath,
});
}
function repairElectronInstall({ function repairElectronInstall({
packageRoot, packageRoot,
}) { }) {
@@ -153,45 +195,93 @@ function buildMissingElectronError({ packageRoot, distDir, candidatePaths }) {
function resolveElectronBinary({ function resolveElectronBinary({
packageRoot = resolveElectronPackageRoot(), packageRoot = resolveElectronPackageRoot(),
projectRoot = process.cwd(),
platform = process.platform, platform = process.platform,
overrideDistPath = process.env.ELECTRON_OVERRIDE_DIST_PATH || null, overrideDistPath = process.env.ELECTRON_OVERRIDE_DIST_PATH || null,
} = {}) { } = {}) {
if (!packageRoot && !overrideDistPath) { const repairErrors = [];
function resolveCurrentPackageRoot() {
if (packageRoot) return packageRoot;
const conventionalRoot = path.join(projectRoot, 'node_modules', 'electron');
if (fs.existsSync(path.join(conventionalRoot, 'package.json'))) {
packageRoot = conventionalRoot;
return packageRoot;
}
packageRoot = resolveElectronPackageRoot();
return packageRoot;
}
function tryRepair(label, repairFn) {
try {
if (!repairFn()) {
return null;
}
} catch (error) {
repairErrors.push(`${label}: ${error && error.message ? error.message : String(error)}`);
return null;
}
const currentPackageRoot = resolveCurrentPackageRoot();
if (!currentPackageRoot && !overrideDistPath) {
return null;
}
const distDir = overrideDistPath || path.join(currentPackageRoot, 'dist');
return electronBinaryCandidates({ packageRoot: currentPackageRoot, distDir, platform }).find((candidate) =>
fs.existsSync(candidate)
);
}
let currentPackageRoot = resolveCurrentPackageRoot();
if (!currentPackageRoot && !overrideDistPath) {
const installed = tryRepair('Electron dependency install', () =>
runNpmInstall({ packageRoot: projectRoot })
);
if (installed) {
return installed;
}
currentPackageRoot = resolveCurrentPackageRoot();
}
if (!currentPackageRoot && !overrideDistPath) {
throw new Error( throw new Error(
'Electron could not be started because node_modules/electron is not installed.\n\n' + 'Electron could not be started because node_modules/electron is not installed.\n\n' +
'Run `npm install` from the repo root, then try `npm start` again.' 'Run `npm install` from the repo root, then try `npm start` again.'
); );
} }
const distDir = overrideDistPath || path.join(packageRoot, 'dist'); const distDir = overrideDistPath || path.join(currentPackageRoot, 'dist');
const candidatePaths = electronBinaryCandidates({ packageRoot, distDir, platform }); let candidatePaths = electronBinaryCandidates({ packageRoot: currentPackageRoot, distDir, platform });
let resolved = candidatePaths.find((candidate) => fs.existsSync(candidate));
const resolved = candidatePaths.find((candidate) => fs.existsSync(candidate)); if (resolved) {
if (!resolved) { return resolved;
if (packageRoot) {
if (runNpmRebuild({ packageRoot })) {
const rebuilt = electronBinaryCandidates({ packageRoot, distDir, platform }).find((candidate) =>
fs.existsSync(candidate)
);
if (rebuilt) {
return rebuilt;
}
}
if (repairElectronInstall({ packageRoot })) {
const repaired = electronBinaryCandidates({ packageRoot, distDir, platform }).find((candidate) =>
fs.existsSync(candidate)
);
if (repaired) {
return repaired;
}
}
}
throw new Error(buildMissingElectronError({ packageRoot, distDir, candidatePaths }));
} }
return resolved; const repairAttempts = [
['Electron rebuild', () => runNpmRebuild({ packageRoot: currentPackageRoot })],
['Electron install repair', () => repairElectronInstall({ packageRoot: currentPackageRoot })],
['Electron dependency install', () => runNpmInstall({ packageRoot: projectRoot })],
];
for (const [label, repairFn] of repairAttempts) {
const repaired = tryRepair(label, repairFn);
if (repaired) {
return repaired;
}
}
throw new Error(
buildMissingElectronError({
packageRoot: currentPackageRoot,
distDir,
candidatePaths,
}) +
(repairErrors.length
? `\n\nAutomatic repair attempts failed:\n${repairErrors.map((error) => ` - ${error}`).join('\n')}`
: '')
);
} }
module.exports = { module.exports = {
@@ -200,6 +290,7 @@ module.exports = {
readElectronPathHint, readElectronPathHint,
repairElectronInstall, repairElectronInstall,
runNpmRebuild, runNpmRebuild,
runNpmInstall,
sanitizeElectronEnv, sanitizeElectronEnv,
resolveElectronBinary, resolveElectronBinary,
resolveElectronPackageRoot, resolveElectronPackageRoot,
+1 -1
View File
@@ -223,7 +223,7 @@ function createGuide(store) {
function exportOutputs(store, guideId, root, manifest) { function exportOutputs(store, guideId, root, manifest) {
const ast = buildRenderAst(store, guideId); const ast = buildRenderAst(store, guideId);
const formats = ['json', 'markdown', 'html-simple', 'html-rich', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx']; const formats = ['json', 'markdown', 'wikijs', 'html-simple', 'html-rich', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
const outputs = {}; const outputs = {};
for (const format of formats) { for (const format of formats) {
const outDir = path.join(root, 'sample-exports', format); const outDir = path.join(root, 'sample-exports', format);
+61 -37
View File
@@ -9,12 +9,9 @@ const { build, Platform } = require('electron-builder');
const ROOT_DIR = path.resolve(__dirname, '..'); const ROOT_DIR = path.resolve(__dirname, '..');
const PACKAGE_JSON = require(path.join(ROOT_DIR, 'package.json')); const PACKAGE_JSON = require(path.join(ROOT_DIR, 'package.json'));
const RELEASE_DIR = path.resolve(process.env.STEPFORGE_RELEASE_DIR || path.join(ROOT_DIR, 'releases')); const APP_ID = 'com.stepforge.app';
const WORK_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'stepforge-win-'));
const OUTPUT_DIR = path.join(WORK_DIR, 'output');
const ARTIFACT_NAME = 'stepforge-windows-x64-portable.exe';
function findPortableExe(dir) { function findInstallerExe(dir) {
if (!fs.existsSync(dir)) return null; if (!fs.existsSync(dir)) return null;
const stack = [dir]; const stack = [dir];
while (stack.length) { while (stack.length) {
@@ -31,15 +28,12 @@ function findPortableExe(dir) {
return null; return null;
} }
async function main() { function createWindowsInstallerConfig(outputDir) {
fs.mkdirSync(RELEASE_DIR, { recursive: true }); return {
fs.rmSync(OUTPUT_DIR, { recursive: true, force: true }); appId: APP_ID,
const config = {
appId: 'com.stepforge.app',
productName: 'StepForge', productName: 'StepForge',
directories: { directories: {
output: OUTPUT_DIR, output: outputDir,
}, },
files: [ files: [
'app/**/*', 'app/**/*',
@@ -49,33 +43,63 @@ async function main() {
], ],
asar: true, asar: true,
compression: 'normal', compression: 'normal',
artifactName: ARTIFACT_NAME,
win: { win: {
target: ['portable'], target: ['nsis'],
},
nsis: {
oneClick: false,
allowToChangeInstallationDirectory: true,
createDesktopShortcut: true,
createStartMenuShortcut: true,
shortcutName: 'StepForge',
}, },
}; };
try {
await build({
targets: Platform.WINDOWS.createTarget('portable'),
config,
});
} catch (err) {
throw new Error(`Windows portable build failed: ${err.message}`);
}
const builtExe = findPortableExe(OUTPUT_DIR);
if (!builtExe) {
throw new Error(`No .exe artifact was produced in ${OUTPUT_DIR}`);
}
const releaseExe = path.join(RELEASE_DIR, path.basename(builtExe));
fs.copyFileSync(builtExe, releaseExe);
console.log(`StepForge ${PACKAGE_JSON.version} Windows portable build written to ${releaseExe}`);
} }
main().catch((err) => { function createWindowsInstallerBuildOptions(outputDir) {
console.error(err.message || err); return {
process.exitCode = 1; targets: Platform.WINDOWS.createTarget('nsis'),
}); config: createWindowsInstallerConfig(outputDir),
publish: 'never',
};
}
async function buildWindowsInstaller() {
const releaseDir = path.resolve(process.env.STEPFORGE_RELEASE_DIR || path.join(ROOT_DIR, 'releases'));
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stepforge-win-'));
const outputDir = path.join(workDir, 'output');
fs.mkdirSync(releaseDir, { recursive: true });
fs.rmSync(outputDir, { recursive: true, force: true });
try {
await build(createWindowsInstallerBuildOptions(outputDir));
} catch (err) {
throw new Error(`Windows installer build failed: ${err.message}`);
}
const builtInstaller = findInstallerExe(outputDir);
if (!builtInstaller) {
throw new Error(`No installer .exe artifact was produced in ${outputDir}`);
}
const releaseInstaller = path.join(releaseDir, path.basename(builtInstaller));
fs.copyFileSync(builtInstaller, releaseInstaller);
console.log(`StepForge ${PACKAGE_JSON.version} Windows installer written to ${releaseInstaller}`);
}
if (require.main === module) {
buildWindowsInstaller().catch((err) => {
console.error(err.message || err);
process.exitCode = 1;
});
}
module.exports = {
APP_ID,
createWindowsInstallerConfig,
createWindowsInstallerBuildOptions,
findInstallerExe,
buildWindowsInstaller,
};
@@ -21,7 +21,7 @@ for f in sample-manifest.json sample-guide.sfgz; do
done done
for dir in sample-data sample-exports/json sample-exports/markdown sample-exports/html-simple \ for dir in sample-data sample-exports/json sample-exports/markdown sample-exports/html-simple \
sample-exports/html-rich sample-exports/pdf sample-exports/gif \ sample-exports/wikijs sample-exports/html-rich sample-exports/pdf sample-exports/gif \
sample-exports/image-bundle sample-exports/docx sample-exports/pptx; do sample-exports/image-bundle sample-exports/docx sample-exports/pptx; do
if ! find "$SAMPLE_ROOT/$dir" -type f -print -quit | grep -q .; then if ! find "$SAMPLE_ROOT/$dir" -type f -print -quit | grep -q .; then
echo "Sample export directory is empty: $dir" >&2 echo "Sample export directory is empty: $dir" >&2
@@ -34,7 +34,7 @@ const fs = require('node:fs');
const manifest = JSON.parse(fs.readFileSync(process.env.MANIFEST_FILE, 'utf8')); const manifest = JSON.parse(fs.readFileSync(process.env.MANIFEST_FILE, 'utf8'));
if (manifest.format !== 'stepforge-sample-manifest') throw new Error('unexpected sample manifest format'); if (manifest.format !== 'stepforge-sample-manifest') throw new Error('unexpected sample manifest format');
if (!manifest.guideId) throw new Error('missing guideId'); if (!manifest.guideId) throw new Error('missing guideId');
if (!manifest.exports || Object.keys(manifest.exports).length < 9) throw new Error('missing sample exports'); if (!manifest.exports || Object.keys(manifest.exports).length < 10) throw new Error('missing sample exports');
NODE NODE
echo "sample artifacts OK" echo "sample artifacts OK"
+1 -1
View File
@@ -375,7 +375,7 @@ test('queued click captures preserve the original event time and button', async
assert.deepEqual(seen, [{ assert.deepEqual(seen, [{
trigger: 'click', trigger: 'click',
clickPos: { x: 7, y: 8 }, clickPos: { x: 7, y: 8 },
clickMeta: { at: 1770000000456, button: 'left' }, clickMeta: { at: 1770000000456, button: 'left', keyContext: { recentTyped: '', recentShortcut: '' } },
}]); }]);
}); });
+40
View File
@@ -112,6 +112,46 @@ test('rebuilds Electron through npm when the binary is missing', (t) => {
); );
}); });
test('falls back to npm install when rebuild does not repair the runtime', (t) => {
const root = makeTmpDir('electron-install-fallback');
t.after(() => rmrf(root));
fs.mkdirSync(path.join(root, 'dist'), { recursive: true });
const fakeNpmCli = path.join(root, 'fake-npm-cli.js');
fs.writeFileSync(
fakeNpmCli,
[
"const fs = require('node:fs');",
"const path = require('node:path');",
"const command = process.argv[2];",
"if (command === 'rebuild') process.exit(1);",
"if (command === 'install') {",
" fs.mkdirSync(path.join(__dirname, 'dist'), { recursive: true });",
" fs.writeFileSync(path.join(__dirname, 'dist', 'electron.exe'), 'binary');",
" fs.writeFileSync(path.join(__dirname, 'path.txt'), 'electron.exe');",
" process.exit(0);",
"}",
"process.exit(1);",
].join('\n')
);
const originalNpmExecPath = process.env.npm_execpath;
const originalNpmNodeExecPath = process.env.npm_node_execpath;
process.env.npm_execpath = fakeNpmCli;
process.env.npm_node_execpath = process.execPath;
t.after(() => {
if (originalNpmExecPath === undefined) delete process.env.npm_execpath;
else process.env.npm_execpath = originalNpmExecPath;
if (originalNpmNodeExecPath === undefined) delete process.env.npm_node_execpath;
else process.env.npm_node_execpath = originalNpmNodeExecPath;
});
assert.equal(
resolveElectronBinary({ packageRoot: root, projectRoot: root, platform: 'win32' }),
path.join(root, 'dist', 'electron.exe')
);
});
test('reports a helpful error when the runtime is missing', (t) => { test('reports a helpful error when the runtime is missing', (t) => {
const root = makeTmpDir('electron-missing'); const root = makeTmpDir('electron-missing');
t.after(() => rmrf(root)); t.after(() => rmrf(root));
+73
View File
@@ -0,0 +1,73 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runExportInWorker } = require('../../app/export-runner');
const { buildRenderAst } = require('../../core/renderast');
const { runExport } = require('../../exporters');
const { buildFixtureGuide } = require('./fixture-guide');
const { makeTmpDir, rmrf } = require('./helpers');
test('export helper process produces the same result as an in-process export', async (t) => {
const root = makeTmpDir('exportworker');
t.after(() => rmrf(root));
const { store, guide } = buildFixtureGuide(path.join(root, 'data'));
const expected = runExport('json', buildRenderAst(store, guide.guideId), path.join(root, 'inproc'));
const result = await runExportInWorker({
dataDir: store.root,
guideId: guide.guideId,
format: 'json',
options: {},
outDir: path.join(root, 'worker'),
globals: {},
});
assert.equal(result.imageCount, expected.imageCount);
assert.ok(fs.existsSync(result.file));
const fromWorker = JSON.parse(fs.readFileSync(result.file, 'utf8'));
const fromInProcess = JSON.parse(fs.readFileSync(expected.file, 'utf8'));
// Each build stamps its own generatedAt; everything else must match exactly.
delete fromWorker.generatedAt;
delete fromInProcess.generatedAt;
assert.deepEqual(fromWorker, fromInProcess);
});
test('export helper process rejects on an unknown format', async (t) => {
const root = makeTmpDir('exportworkerbadfmt');
t.after(() => rmrf(root));
const { store, guide } = buildFixtureGuide(path.join(root, 'data'));
await assert.rejects(
runExportInWorker({
dataDir: store.root,
guideId: guide.guideId,
format: 'exe',
options: {},
outDir: path.join(root, 'out'),
globals: {},
}),
/unknown export format/,
);
});
test('export helper process rejects on an unknown guide id', async (t) => {
const root = makeTmpDir('exportworkerbadguide');
t.after(() => rmrf(root));
const { store } = buildFixtureGuide(path.join(root, 'data'));
await assert.rejects(
runExportInWorker({
dataDir: store.root,
guideId: 'guide_does_not_exist',
format: 'json',
options: {},
outDir: path.join(root, 'out'),
globals: {},
}),
);
});
+245 -7
View File
@@ -4,8 +4,10 @@ const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const zlib = require('node:zlib');
const { execFileSync } = require('node:child_process'); const { execFileSync } = require('node:child_process');
const { GuideStore } = require('../../core/store');
const { buildRenderAst } = require('../../core/renderast'); const { buildRenderAst } = require('../../core/renderast');
const { exportPdf } = require('../../exporters/pdf'); const { exportPdf } = require('../../exporters/pdf');
const { exportGifGuide } = require('../../exporters/gif'); const { exportGifGuide } = require('../../exporters/gif');
@@ -25,6 +27,52 @@ function hasTool(cmd) {
try { execFileSync('which', [cmd], { stdio: 'pipe' }); return true; } catch { return false; } try { execFileSync('which', [cmd], { stdio: 'pipe' }); return true; } catch { return false; }
} }
/** Inflate each page's content stream, in page order, for op-level assertions. */
function pageContents(buf) {
const text = buf.toString('latin1');
const out = [];
const re = /\d+ 0 obj\n<< \/Filter \/FlateDecode \/Length (\d+) >>\nstream\n/g;
let m;
while ((m = re.exec(text)) !== null) {
const len = Number(m[1]);
const start = m.index + m[0].length;
out.push(zlib.inflateSync(buf.subarray(start, start + len)).toString('latin1'));
}
return out;
}
/** Map each step bookmark's title to the 0-based page index it lands on. */
function bookmarkPages(buf) {
const text = buf.toString('latin1');
const kids = [...text.match(/\/Type \/Pages \/Kids \[([^\]]+)\]/)[1].matchAll(/(\d+) 0 R/g)]
.map((m) => Number(m[1]));
const pageIndexOf = new Map(kids.map((id, i) => [id, i]));
const out = [];
for (const m of text.matchAll(/\/Title \(([^)]*)\)([^>]*)/g)) {
const dest = /\/Dest \[(\d+) 0 R/.exec(m[2]);
if (dest) out.push({ title: m[1], pageIndex: pageIndexOf.get(Number(dest[1])) });
}
return out;
}
/** Resolve each Link annotation's `/Dest` on the page at `pageIndex` to a 0-based page index. */
function tocLinkTargets(buf, pageIndex) {
const text = buf.toString('latin1');
const kids = [...text.match(/\/Type \/Pages \/Kids \[([^\]]+)\]/)[1].matchAll(/(\d+) 0 R/g)]
.map((m) => Number(m[1]));
const pageIndexOf = new Map(kids.map((id, i) => [id, i]));
const objBody = (id) => new RegExp(`(?:^|\\n)${id} 0 obj\\n([\\s\\S]*?)\\nendobj`).exec(text)[1];
const annots = /\/Annots \[([^\]]+)\]/.exec(objBody(kids[pageIndex]));
if (!annots) return [];
return [...annots[1].matchAll(/(\d+) 0 R/g)].map((m) => {
const body = objBody(Number(m[1]));
assert.match(body, /\/Subtype \/Link/);
const dest = /\/Dest \[(\d+) 0 R/.exec(body);
return pageIndexOf.get(Number(dest[1]));
});
}
/** Tiny XML well-formedness check: balanced tags, single root. */ /** Tiny XML well-formedness check: balanced tags, single root. */
function assertWellFormedXml(xml, label) { function assertWellFormedXml(xml, label) {
const body = xml.replace(/<\?xml[^?]*\?>/, '').trim(); const body = xml.replace(/<\?xml[^?]*\?>/, '').trim();
@@ -73,6 +121,20 @@ test('PDF export: valid document, bookmarks per step, images embedded', (t) => {
assert.equal((text.match(/\/Subtype \/Image/g) || []).length, 2); assert.equal((text.match(/\/Subtype \/Image/g) || []).length, 2);
}); });
test('PDF Contents: each entry is a clickable link to its step\'s page', (t) => {
const { ast, root } = fixtureAst(t, 'pdftoc');
const buf = fs.readFileSync(exportPdf(ast, path.join(root, 'out')).file);
const bookmarks = bookmarkPages(buf); // one per step, in document order
const tocTargets = tocLinkTargets(buf, 1); // page 0 = cover, page 1 = Contents
const tocPage = pageContents(buf)[1];
const blueTocLines = [...tocPage.matchAll(/BT \/F1 10\.5 Tf 0\.145 0\.388 0\.922 rg 1 0 0 1 [\d.]+ [\d.]+ Tm \(([^)]*)\) Tj ET/g)]
.map((m) => m[1]);
assert.deepEqual(tocTargets, bookmarks.map((b) => b.pageIndex));
assert.deepEqual(blueTocLines, ast.steps.map((step) => `${step.number}. ${step.title || 'Untitled step'}`));
});
test('PDF renders under Ghostscript end-to-end', { skip: !hasTool('gs') }, (t) => { test('PDF renders under Ghostscript end-to-end', { skip: !hasTool('gs') }, (t) => {
const { ast, root } = fixtureAst(t, 'pdfgs'); const { ast, root } = fixtureAst(t, 'pdfgs');
const { file, pageCount } = exportPdf(ast, path.join(root, 'out')); const { file, pageCount } = exportPdf(ast, path.join(root, 'out'));
@@ -80,6 +142,83 @@ test('PDF renders under Ghostscript end-to-end', { skip: !hasTool('gs') }, (t) =
assert.match(out, new RegExp(`Processing pages 1 through ${pageCount}`)); assert.match(out, new RegExp(`Processing pages 1 through ${pageCount}`));
}); });
test('PDF cover: title in big text above the accent rule, guide metadata below it', (t) => {
const root = makeTmpDir('pdfcover');
t.after(() => rmrf(root));
const { store, guide: bare } = buildFixtureGuide(path.join(root, 'data'));
// No metadata set: cover has no Author/Co-authors/Organization lines.
const astNoMeta = buildRenderAst(store, bare.guideId);
const { file: fileNoMeta } = exportPdf(astNoMeta, path.join(root, 'out1'));
const coverNoMeta = pageContents(fs.readFileSync(fileNoMeta))[0];
assert.ok(!coverNoMeta.includes('Author:'));
assert.ok(!coverNoMeta.includes('Co-authors:'));
assert.ok(!coverNoMeta.includes('Organization:'));
// Set guide metadata, then re-export.
const guide = store.getGuide(bare.guideId);
guide.metadata = { author: 'Jane Doe', coAuthors: 'Alex Lee', organization: 'Acme Corp' };
store.saveGuide(guide);
const ast = buildRenderAst(store, guide.guideId);
const { file } = exportPdf(ast, path.join(root, 'out2'));
const cover = pageContents(fs.readFileSync(file))[0];
assert.ok(cover.includes('Author: Jane Doe'));
assert.ok(cover.includes('Co-authors: Alex Lee'));
assert.ok(cover.includes('Organization: Acme Corp'));
// Title (28pt, F2) sits above the accent rule (a 3pt-tall filled rect),
// which sits above the metadata lines (11pt, F1). PDF y increases
// upward, so items higher on the page have larger y values.
const titleY = Number(/\/F2 28 Tf [\d.]+ [\d.]+ [\d.]+ rg 1 0 0 1 [\d.]+ ([\d.]+) Tm \(Configure AcmeSync backups\) Tj/.exec(cover)[1]);
const ruleY = Number(/[\d.]+ [\d.]+ [\d.]+ rg ([\d.]+) ([\d.]+) [\d.]+ 3\.00 re f/.exec(cover)[2]);
const authorY = Number(/\/F1 11 Tf [\d.]+ [\d.]+ [\d.]+ rg 1 0 0 1 [\d.]+ ([\d.]+) Tm \(Author: Jane Doe\) Tj/.exec(cover)[1]);
assert.ok(titleY > 740, 'title starts near the top edge');
assert.ok(titleY > ruleY, 'title sits above the accent rule');
assert.ok(ruleY > authorY, 'metadata sits below the accent rule');
});
test('PDF pagination: short steps pack onto a page; a step that does not fit moves to a fresh page', (t) => {
const root = makeTmpDir('pdfpage');
t.after(() => rmrf(root));
const store = new GuideStore(path.join(root, 'data'));
const guide = store.createGuide({ title: 'Pagination test' });
const filler = (n) => `<p>${'Lorem ipsum dolor sit amet consectetur. '.repeat(n)}</p>`;
store.addStep(guide.guideId, { kind: 'empty', title: 'Step A', descriptionHtml: '<p>Short content A.</p>' });
store.addStep(guide.guideId, { kind: 'empty', title: 'Step B', descriptionHtml: '<p>Short content B.</p>' });
// Doesn't fit in what's left of page 1, but fits comfortably on its own
// page — with enough room left for step D to pack onto that same page.
store.addStep(guide.guideId, { kind: 'empty', title: 'Step C', descriptionHtml: filler(95) });
store.addStep(guide.guideId, { kind: 'empty', title: 'Step D', descriptionHtml: '<p>Short content D.</p>' });
const ast = buildRenderAst(store, guide.guideId);
const { file, pageCount } = exportPdf(ast, path.join(root, 'out'), { includeCover: false, includeToc: false });
const pages = bookmarkPages(fs.readFileSync(file));
assert.deepEqual(pages.map((p) => p.pageIndex), [0, 0, 1, 1]);
assert.equal(pageCount, 2);
});
test('PDF pagination: a step longer than one page starts fresh and overflows; the next step starts on a new page', (t) => {
const root = makeTmpDir('pdfpage2');
t.after(() => rmrf(root));
const store = new GuideStore(path.join(root, 'data'));
const guide = store.createGuide({ title: 'Pagination overflow test' });
const filler = (n) => `<p>${'Lorem ipsum dolor sit amet consectetur. '.repeat(n)}</p>`;
store.addStep(guide.guideId, { kind: 'empty', title: 'Step A', descriptionHtml: '<p>Short content A.</p>' });
store.addStep(guide.guideId, { kind: 'empty', title: 'Step B', descriptionHtml: '<p>Short content B.</p>' });
// Longer than a full page: starts on its own page and overflows onto the next.
store.addStep(guide.guideId, { kind: 'empty', title: 'Step C', descriptionHtml: filler(120) });
store.addStep(guide.guideId, { kind: 'empty', title: 'Step D', descriptionHtml: '<p>Short content D.</p>' });
const ast = buildRenderAst(store, guide.guideId);
const { file, pageCount } = exportPdf(ast, path.join(root, 'out'), { includeCover: false, includeToc: false });
const pages = bookmarkPages(fs.readFileSync(file));
// A and B pack onto page 0; C starts fresh on page 1 and overflows onto
// page 2; D is forced onto a fresh page 3 rather than sharing C's spillover.
assert.deepEqual(pages.map((p) => p.pageIndex), [0, 0, 1, 3]);
assert.equal(pageCount, 4);
});
test('GIF export: title card + one frame per image step, valid animation', (t) => { test('GIF export: title card + one frame per image step, valid animation', (t) => {
const { ast, root } = fixtureAst(t, 'gifx'); const { ast, root } = fixtureAst(t, 'gifx');
const { file, frameCount } = exportGifGuide(ast, path.join(root, 'out'), { width: 320 }); const { file, frameCount } = exportGifGuide(ast, path.join(root, 'out'), { width: 320 });
@@ -146,35 +285,105 @@ test('DOCX export: valid OPC package, well-formed XML, resolvable image rels', (
assert.equal(imageCount, 2); assert.equal(imageCount, 2);
const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data])); const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data]));
for (const required of ['[Content_Types].xml', '_rels/.rels', 'word/document.xml', 'word/_rels/document.xml.rels']) { for (const required of ['[Content_Types].xml', '_rels/.rels', 'word/document.xml', 'word/_rels/document.xml.rels', 'word/settings.xml', 'word/styles.xml']) {
assert.ok(entries.has(required), `missing ${required}`); assert.ok(entries.has(required), `missing ${required}`);
} }
assertWellFormedXml(entries.get('word/document.xml').toString('utf8'), 'document.xml'); assertWellFormedXml(entries.get('word/document.xml').toString('utf8'), 'document.xml');
assertWellFormedXml(entries.get('[Content_Types].xml').toString('utf8'), 'content types'); assertWellFormedXml(entries.get('[Content_Types].xml').toString('utf8'), 'content types');
assertWellFormedXml(entries.get('word/settings.xml').toString('utf8'), 'settings.xml');
assertWellFormedXml(entries.get('word/styles.xml').toString('utf8'), 'styles.xml');
// Every relationship target exists in the package, every embed has a rel. // Every relationship target exists in the package, every embed has a rel.
const relsXml = entries.get('word/_rels/document.xml.rels').toString('utf8'); const relsXml = entries.get('word/_rels/document.xml.rels').toString('utf8');
const relTargets = [...relsXml.matchAll(/Target="([^"]+)"/g)].map((m) => m[1]); const relTargets = [...relsXml.matchAll(/Target="([^"]+)"/g)].map((m) => m[1]);
assert.equal(relTargets.length, 2); assert.equal(relTargets.length, 4);
for (const target of relTargets) { assert.ok(relTargets.includes('settings.xml'));
assert.ok(relTargets.includes('styles.xml'));
const mediaTargets = relTargets.filter((target) => target.startsWith('media/'));
assert.equal(mediaTargets.length, 2);
const iconTargets = mediaTargets.filter((target) => target.includes('callout-'));
const imageTargets = mediaTargets.filter((target) => target.includes('image'));
assert.equal(iconTargets.length, 0);
assert.equal(imageTargets.length, 2);
for (const target of iconTargets) {
assert.ok(entries.has(`word/${target}`), `relationship target ${target} present`);
const img = decodePng(entries.get(`word/${target}`));
assert.equal(img.width, 24);
}
for (const target of imageTargets) {
assert.ok(entries.has(`word/${target}`), `relationship target ${target} present`); assert.ok(entries.has(`word/${target}`), `relationship target ${target} present`);
const img = decodePng(entries.get(`word/${target}`)); const img = decodePng(entries.get(`word/${target}`));
assert.equal(img.width, 320); assert.equal(img.width, 320);
} }
const docXml = entries.get('word/document.xml').toString('utf8'); const docXml = entries.get('word/document.xml').toString('utf8');
const embeds = [...docXml.matchAll(/r:embed="(rId\d+)"/g)].map((m) => m[1]); const embeds = [...docXml.matchAll(/r:embed="(rId\d+)"/g)].map((m) => m[1]);
const relIds = [...relsXml.matchAll(/Id="(rId\d+)"/g)].map((m) => m[1]); const relIds = [...relsXml.matchAll(/Id="(rId\d+)"/g)].map((m) => m[1]).filter((id) => id !== 'rId1');
assert.deepEqual(embeds.sort(), relIds.sort()); for (const id of embeds) {
assert.ok(relIds.includes(id), `missing relationship for ${id}`);
}
assert.ok(docXml.includes('w:fldChar w:fldCharType="begin" w:dirty="true"'));
assert.ok(docXml.includes('TOC \\o "1-3" \\h \\z \\u'));
assert.ok(docXml.includes('w:pStyle w:val="Heading1"'));
assert.ok(docXml.includes('w:pStyle w:val="Heading2"'));
assert.ok(docXml.includes('w:outlineLvl w:val="0"'));
assert.ok(docXml.includes('w:outlineLvl w:val="1"'));
const stylesXml = entries.get('word/styles.xml').toString('utf8');
assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="Heading1"'));
assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="Heading2"'));
assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="Heading3"'));
// unzip CLI also accepts the package (it is a plain zip). // unzip CLI also accepts the package (it is a plain zip).
assert.ok(entries.size >= 6); assert.ok(entries.size >= 6);
}); });
test('DOCX export: TOC contains a real, navigable entry per step', (t) => {
const { ast, root } = fixtureAst(t, 'docxtoc');
const { file } = exportDocx(ast, path.join(root, 'out'));
const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data]));
const docXml = entries.get('word/document.xml').toString('utf8');
const stylesXml = entries.get('word/styles.xml').toString('utf8');
// One TOC entry per step, in document order, hyperlinked by step number.
const tocLinks = [...docXml.matchAll(/<w:hyperlink w:anchor="([^"]+)">(.*?)<\/w:hyperlink>/g)]
.map((m) => ({ anchor: m[1], text: /<w:t[^>]*>([^<]*)<\/w:t>/.exec(m[2])[1] }));
assert.deepEqual(tocLinks.map((e) => e.text), ast.steps.map((step) => `${step.number}. ${step.title || 'Untitled step'}`));
// Each TOC entry's anchor matches a bookmark wrapping that step's heading.
for (const { anchor } of tocLinks) {
assert.match(docXml, new RegExp(`<w:bookmarkStart w:id="\\d+" w:name="${anchor}"/>`), `bookmark for ${anchor}`);
}
// Bookmark ids are unique.
const bookmarkIds = [...docXml.matchAll(/<w:bookmarkStart w:id="(\d+)"/g)].map((m) => m[1]);
assert.equal(new Set(bookmarkIds).size, bookmarkIds.length);
// Page numbers are real PAGEREF fields (one per entry), not static text.
const pageRefs = [...docXml.matchAll(/PAGEREF (\S+) \\h/g)].map((m) => m[1]);
assert.deepEqual(pageRefs, tocLinks.map((e) => e.anchor));
// The outer TOC field still wraps the whole table (so Word can refresh it).
assert.ok(docXml.includes('w:fldChar w:fldCharType="begin" w:dirty="true"'));
assert.ok(docXml.includes('TOC \\o "1-3" \\h \\z \\u'));
assert.ok(docXml.includes('w:pStyle w:val="Heading1"'));
assert.ok(docXml.includes('w:pStyle w:val="Heading2"'));
assert.ok(docXml.includes('w:outlineLvl w:val="0"'));
assert.ok(docXml.includes('w:outlineLvl w:val="1"'));
// TOC entry styles are defined and indent deeper levels further.
assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="TOC1"'));
assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="TOC2"'));
assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="TOC3"'));
assertWellFormedXml(docXml, 'document.xml');
});
test('PPTX export: slides per step, master/layout/theme present, rels resolve', (t) => { test('PPTX export: slides per step, master/layout/theme present, rels resolve', (t) => {
const { ast, root } = fixtureAst(t, 'pptx'); const { ast, root } = fixtureAst(t, 'pptx');
const { file, slideCount, imageCount } = exportPptx(ast, path.join(root, 'out')); const { file, slideCount, imageCount } = exportPptx(ast, path.join(root, 'out'));
assert.equal(slideCount, 4, 'title slide + 3 steps'); assert.equal(slideCount, 5, 'title slide + contents slide + 3 steps');
assert.equal(imageCount, 2); assert.equal(imageCount, 2);
const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data])); const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data]));
for (const required of [ for (const required of [
@@ -192,6 +401,10 @@ test('PPTX export: slides per step, master/layout/theme present, rels resolve',
// presentation.xml references each slide and the count matches. // presentation.xml references each slide and the count matches.
const pres = entries.get('ppt/presentation.xml').toString('utf8'); const pres = entries.get('ppt/presentation.xml').toString('utf8');
assert.equal((pres.match(/<p:sldId /g) || []).length, slideCount); assert.equal((pres.match(/<p:sldId /g) || []).length, slideCount);
assert.ok(entries.get('ppt/slides/slide2.xml').toString('utf8').includes('Contents'));
const slide4 = entries.get('ppt/slides/slide4.xml').toString('utf8');
assert.ok(slide4.includes('Warning: Access'));
assert.ok(slide4.includes('Admins only.'));
// image rels on slides resolve to media files. // image rels on slides resolve to media files.
for (let i = 1; i <= slideCount; i++) { for (let i = 1; i <= slideCount; i++) {
const rels = entries.get(`ppt/slides/_rels/slide${i}.xml.rels`).toString('utf8'); const rels = entries.get(`ppt/slides/_rels/slide${i}.xml.rels`).toString('utf8');
@@ -199,6 +412,31 @@ test('PPTX export: slides per step, master/layout/theme present, rels resolve',
assert.ok(entries.has(`ppt/media/${m[1]}`), `media ${m[1]} present`); assert.ok(entries.has(`ppt/media/${m[1]}`), `media ${m[1]} present`);
} }
} }
const mediaTargets = [...entries.keys()].filter((name) => name.startsWith('ppt/media/'));
assert.equal(mediaTargets.length, 2);
assert.ok(!mediaTargets.some((name) => name.includes('callout-')));
});
test('PPTX export: TOC paginates onto additional slides before it would overflow', (t) => {
const root = makeTmpDir('pptxtoc');
t.after(() => rmrf(root));
const store = new GuideStore(path.join(root, 'data'));
const guide = store.createGuide({ title: 'Large TOC test' });
for (let i = 1; i <= 40; i++) {
store.addStep(guide.guideId, { kind: 'empty', title: `Step ${i}` });
}
const ast = buildRenderAst(store, guide.guideId);
const { file, slideCount } = exportPptx(ast, path.join(root, 'out'));
const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data]));
const slideXmls = Array.from({ length: slideCount }, (_, i) => entries.get(`ppt/slides/slide${i + 1}.xml`).toString('utf8'));
const tocSlides = slideXmls.filter((xml) => xml.includes('Contents'));
assert.ok(tocSlides.length >= 2, 'large TOC should span multiple slides');
assert.ok(tocSlides[0].includes('1. Step 1'));
assert.ok(!tocSlides[0].includes('40. Step 40'));
assert.ok(tocSlides.at(-1).includes('40. Step 40'));
}); });
test('template manager: save/load/rename/duplicate/delete and .sfglt round-trip', (t) => { test('template manager: save/load/rename/duplicate/delete and .sfglt round-trip', (t) => {
@@ -237,6 +475,6 @@ test('a saved template changes exporter behavior through runExport', (t) => {
const withTemplate = runExport('pdf', ast, path.join(root, 'out2'), tm.load('pdf', 'no-cover')); const withTemplate = runExport('pdf', ast, path.join(root, 'out2'), tm.load('pdf', 'no-cover'));
assert.ok(withTemplate.pageCount < withDefaults.pageCount, 'dropping cover+toc reduces pages'); assert.ok(withTemplate.pageCount < withDefaults.pageCount, 'dropping cover+toc reduces pages');
assert.equal(Object.keys(EXPORTERS).length, 10, 'all ten formats wired'); assert.equal(Object.keys(EXPORTERS).length, 11, 'all eleven formats wired');
assert.throws(() => runExport('exe', ast, path.join(root, 'out3'))); assert.throws(() => runExport('exe', ast, path.join(root, 'out3')));
}); });
+127 -5
View File
@@ -8,9 +8,11 @@ const path = require('node:path');
const { buildRenderAst, renderStepImage } = require('../../core/renderast'); const { buildRenderAst, renderStepImage } = require('../../core/renderast');
const { exportJson } = require('../../exporters/json'); const { exportJson } = require('../../exporters/json');
const { exportMarkdown } = require('../../exporters/markdown'); const { exportMarkdown } = require('../../exporters/markdown');
const { exportWikiJs } = require('../../exporters/wikijs');
const { exportHtmlSimple, exportHtmlRich } = require('../../exporters/html'); const { exportHtmlSimple, exportHtmlRich } = require('../../exporters/html');
const { exportConfluence } = require('../../exporters/confluence'); const { exportConfluence } = require('../../exporters/confluence');
const { htmlToMarkdown } = require('../../exporters/htmlmd'); const { htmlToMarkdown } = require('../../exporters/htmlmd');
const { stepContentGroups } = require('../../exporters/common');
const { decodePng } = require('../../core/png'); const { decodePng } = require('../../core/png');
const { buildFixtureGuide } = require('./fixture-guide'); const { buildFixtureGuide } = require('./fixture-guide');
const { makeTmpDir, rmrf } = require('./helpers'); const { makeTmpDir, rmrf } = require('./helpers');
@@ -44,6 +46,33 @@ test('render AST: numbering, placeholder expansion, hidden/skipped filtering', (
assert.deepEqual([img.data[p], img.data[p + 1], img.data[p + 2]], [255, 0, 0]); assert.deepEqual([img.data[p], img.data[p + 1], img.data[p + 2]], [255, 0, 0]);
}); });
test('render AST: guide metadata defaults to empty strings and expands placeholders', (t) => {
const root = makeTmpDir('astmeta');
t.after(() => rmrf(root));
const { store, guide: bare } = buildFixtureGuide(path.join(root, 'data'));
// Fixture guide has no metadata set: all fields default to ''.
const noMeta = buildRenderAst(store, bare.guideId);
assert.deepEqual(noMeta.guide.metadata, { author: '', coAuthors: '', organization: '' });
// Set metadata with placeholders and re-check expansion against guide + global scope.
const guide = store.getGuide(bare.guideId);
guide.metadata = {
author: '[[Author]]',
coAuthors: 'Alex Lee, [[CoAuthor]]',
organization: '[[Org]]',
};
store.saveGuide(guide);
const ast = buildRenderAst(store, guide.guideId, { globals: { CoAuthor: 'Sam Patel', Org: 'GlobalOrg' } });
// Guide-level placeholder (Author -> Casey) wins over global; CoAuthor/Org fall back to globals.
assert.deepEqual(ast.guide.metadata, {
author: 'Casey',
coAuthors: 'Alex Lee, Sam Patel',
organization: 'GlobalOrg',
});
});
test('JSON export produces a parseable document with real image files', (t) => { test('JSON export produces a parseable document with real image files', (t) => {
const root = makeTmpDir('expjson'); const root = makeTmpDir('expjson');
t.after(() => rmrf(root)); t.after(() => rmrf(root));
@@ -109,10 +138,103 @@ test('Markdown export: TOC anchors resolve, images exist, blocks rendered', (t)
assert.equal(lines[fenceStart + 1], '0 2 * * * /usr/local/bin/acmesync --backup'); assert.equal(lines[fenceStart + 1], '0 2 * * * /usr/local/bin/acmesync --backup');
assert.equal(lines[fenceStart + 2], '```'); assert.equal(lines[fenceStart + 2], '```');
assert.ok(lines.some((l) => /^\| Day \| Window \|$/.test(l)), 'table header row'); assert.ok(lines.some((l) => /^\| Day \| Window \|$/.test(l)), 'table header row');
// Warning text block became a blockquote with its content. // Warning text block became a styled HTML callout with its content.
const warnIdx = lines.findIndex((l) => l.startsWith('> **Warning: Access**')); assert.ok(md.includes('<div class="sf-callout sf-callout-warning"'));
assert.ok(warnIdx > 0); assert.ok(md.includes('border-left: 4px solid #f59e0b'));
assert.equal(lines[warnIdx + 1], '> Admins only.'); assert.ok(!md.includes('background: #fffbeb'));
assert.ok(md.includes('color: inherit;'));
assert.ok(md.includes('Warning: Access'));
assert.ok(md.includes('<p>Admins only.</p>'));
});
test('text block positions render around the title, description, and image', (t) => {
const root = makeTmpDir('exppositions');
t.after(() => rmrf(root));
const { store, guide, s1 } = buildFixtureGuide(path.join(root, 'data'));
const step = store.getStep(guide.guideId, s1.stepId);
step.textBlocks = [
{ id: 'tb-before-title', order: 1, position: 'before-title', level: 'info', title: 'Before title', descriptionHtml: '<p>Before title.</p>' },
{ id: 'tb-after-title', order: 2, position: 'after-title', level: 'info', title: 'After title', descriptionHtml: '<p>After title.</p>' },
{ id: 'tb-before-description', order: 3, position: 'before-description', level: 'info', title: 'Before description', descriptionHtml: '<p>Before description.</p>' },
{ id: 'tb-after-description', order: 4, position: 'after-description', level: 'info', title: 'After description', descriptionHtml: '<p>After description.</p>' },
{ id: 'tb-before-image', order: 5, position: 'before-image', level: 'info', title: 'Before image', descriptionHtml: '<p>Before image.</p>' },
{ id: 'tb-after-image', order: 6, position: 'after-image', level: 'info', title: 'After image', descriptionHtml: '<p>After image.</p>' },
];
store.saveStep(guide.guideId, step);
const ast = buildRenderAst(store, guide.guideId);
const groups = stepContentGroups(ast.steps[0]);
assert.equal(groups.beforeTitle.length, 1);
assert.equal(groups.afterTitle.length, 1);
assert.equal(groups.beforeDescription.length, 1);
assert.equal(groups.afterDescription.length, 1);
assert.equal(groups.beforeImage.length, 1);
assert.equal(groups.afterImage.length, 1);
const assertIncreasingOrder = (text, markers) => {
let previous = -1;
for (const marker of markers) {
const index = text.indexOf(marker);
assert.ok(index >= 0, `missing marker: ${marker}`);
assert.ok(index > previous, `marker out of order: ${marker}`);
previous = index;
}
};
const mdOut = path.join(root, 'md');
const md = fs.readFileSync(exportMarkdown(ast, mdOut).file, 'utf8');
assertIncreasingOrder(md, [
'Before title',
'## 1. Open AcmeSync settings',
'After title',
'Before description',
'docs.example.com',
'After description',
'Before image',
'![Step 1](',
'After image',
]);
const htmlOut = path.join(root, 'html');
const html = fs.readFileSync(exportHtmlSimple(ast, htmlOut).file, 'utf8');
assertIncreasingOrder(html, [
'Before title',
'<h2>1. Open AcmeSync settings</h2>',
'After title',
'Before description',
'docs.example.com',
'After description',
'Before image',
'alt="Step 1"',
'After image',
]);
});
test('Wiki.js export: TOC is included, wiki callouts render, images exist', (t) => {
const root = makeTmpDir('expwikijs');
t.after(() => rmrf(root));
const { store, guide } = buildFixtureGuide(path.join(root, 'data'));
const out = path.join(root, 'out');
const ast = buildRenderAst(store, guide.guideId);
const { file } = exportWikiJs(ast, out);
const md = fs.readFileSync(file, 'utf8');
const lines = md.split('\n');
assert.equal(lines[0], '# Configure AcmeSync backups');
assert.ok(lines.some((l) => l === '## Contents'));
assert.ok(lines.some((l) => l.startsWith('## 1. Open AcmeSync settings')));
assert.ok(lines.some((l) => l.startsWith('> **Access**')));
assert.ok(lines.includes('> Admins only.'));
assert.ok(lines.includes('{.is-warning}'));
const imgRefs = [...md.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)].map((m) => m[1]);
assert.equal(imgRefs.length, 2);
for (const rel of imgRefs) {
const img = decodePng(fs.readFileSync(path.join(out, rel)));
assert.equal(img.width, 320);
}
}); });
test('Confluence export writes storage-format XML and image attachments', (t) => { test('Confluence export writes storage-format XML and image attachments', (t) => {
@@ -183,7 +305,7 @@ test('Rich HTML export: TOC matches sections, checkboxes per step, local-only pe
const { file } = exportHtmlRich(ast, out); const { file } = exportHtmlRich(ast, out);
const html = fs.readFileSync(file, 'utf8'); const html = fs.readFileSync(file, 'utf8');
const tocAnchors = [...html.matchAll(/<li class="d\d"><a href="#([^"]+)"/g)].map((m) => m[1]); const tocAnchors = [...html.matchAll(/<li class="d\d">\s*<a href="#([^"]+)"/g)].map((m) => m[1]);
const sectionIds = [...html.matchAll(/<section class="step[^"]*" id="([^"]+)"/g)].map((m) => m[1]); const sectionIds = [...html.matchAll(/<section class="step[^"]*" id="([^"]+)"/g)].map((m) => m[1]);
assert.deepEqual(tocAnchors, sectionIds); assert.deepEqual(tocAnchors, sectionIds);
assert.equal(sectionIds.length, 3); assert.equal(sectionIds.length, 3);
+40
View File
@@ -0,0 +1,40 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const assert = require('node:assert/strict');
const { makeTmpDir, rmrf } = require('./helpers');
const {
createWindowsInstallerConfig,
createWindowsInstallerBuildOptions,
findInstallerExe,
} = require('../../scripts/package-windows');
test('Windows packaging uses an assisted NSIS installer', (t) => {
const config = createWindowsInstallerConfig('/tmp/stepforge-output');
assert.deepEqual(config.win.target, ['nsis']);
assert.equal(config.nsis.oneClick, false);
assert.equal(config.nsis.allowToChangeInstallationDirectory, true);
assert.equal(config.nsis.createDesktopShortcut, true);
assert.equal(config.nsis.createStartMenuShortcut, true);
assert.equal(config.nsis.shortcutName, 'StepForge');
assert.equal(config.asar, true);
assert.ok(config.files.includes('app/**/*'));
assert.ok(config.files.includes('core/**/*'));
assert.ok(config.files.includes('exporters/**/*'));
assert.ok(!config.files.includes('assets/**/*'));
const buildOptions = createWindowsInstallerBuildOptions('/tmp/stepforge-output');
assert.equal(buildOptions.publish, 'never');
assert.equal(buildOptions.config.publish, undefined);
const tmp = makeTmpDir('windows-installer');
t.after(() => rmrf(tmp));
fs.mkdirSync(path.join(tmp, 'nested', 'deeper'), { recursive: true });
const installer = path.join(tmp, 'nested', 'deeper', 'StepForge Setup 0.2.0.exe');
fs.writeFileSync(installer, Buffer.from('fake installer'));
assert.equal(findInstallerExe(tmp), installer);
});
+2
View File
@@ -56,12 +56,14 @@ test('settings persist, deep-merge with defaults, and store global placeholders'
assert.equal(s1.get('appearance'), DEFAULT_SETTINGS.appearance); assert.equal(s1.get('appearance'), DEFAULT_SETTINGS.appearance);
s1.set('appearance', 'dark'); s1.set('appearance', 'dark');
s1.set('capture.delayMs', 1500); s1.set('capture.delayMs', 1500);
s1.set('ai.ollama.model', 'qwen3:0.6b');
s1.setGlobalPlaceholders({ Company: 'Acme', Author: 'Tyler' }); s1.setGlobalPlaceholders({ Company: 'Acme', Author: 'Tyler' });
// A fresh instance reads back the changed values merged over defaults. // A fresh instance reads back the changed values merged over defaults.
const s2 = new Settings(dir); const s2 = new Settings(dir);
assert.equal(s2.get('appearance'), 'dark'); assert.equal(s2.get('appearance'), 'dark');
assert.equal(s2.get('capture.delayMs'), 1500); assert.equal(s2.get('capture.delayMs'), 1500);
assert.equal(s2.get('ai.ollama.model'), 'qwen3:0.6b');
assert.equal(s2.get('capture.clickMarker'), DEFAULT_SETTINGS.capture.clickMarker); assert.equal(s2.get('capture.clickMarker'), DEFAULT_SETTINGS.capture.clickMarker);
assert.deepEqual(s2.getGlobalPlaceholders(), { Company: 'Acme', Author: 'Tyler' }); assert.deepEqual(s2.getGlobalPlaceholders(), { Company: 'Acme', Author: 'Tyler' });
}); });
+49
View File
@@ -69,6 +69,31 @@ test('step reorder, delete with substep reparenting, and order integrity', (t) =
assert.equal(store.getStep(guide.guideId, c.stepId).parentStepId, null); assert.equal(store.getStep(guide.guideId, c.stepId).parentStepId, null);
}); });
test('restoreStep recreates a deleted step with its original id, data, and images', (t) => {
const root = makeTmpDir('restore');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'Restore test' });
const a = store.addStep(guide.guideId, { kind: 'empty', title: 'A' });
const b = store.addStep(guide.guideId, { title: 'B', annotations: [{ id: 'ann1', type: 'arrow', x: 1, y: 2 }] }, TINY_PNG, { width: 1, height: 1 });
const c = store.addStep(guide.guideId, { kind: 'empty', title: 'C' });
const deleted = store.getStep(guide.guideId, b.stepId);
store.deleteStep(guide.guideId, b.stepId);
assert.deepEqual(store.getGuide(guide.guideId).stepsOrder, [a.stepId, c.stepId]);
const restored = store.restoreStep(guide.guideId, deleted, { original: TINY_PNG, working: TINY_PNG }, 1);
assert.equal(restored.stepId, b.stepId);
assert.equal(restored.title, 'B');
assert.equal(restored.annotations[0].type, 'arrow');
const after = store.getGuide(guide.guideId);
assert.deepEqual(after.stepsOrder, [a.stepId, b.stepId, c.stepId]);
assert.deepEqual(store.getStep(guide.guideId, b.stepId).annotations, deleted.annotations);
assert.deepEqual(fs.readFileSync(store.stepImagePath(guide.guideId, b.stepId, 'original')), TINY_PNG);
assert.deepEqual(fs.readFileSync(store.stepImagePath(guide.guideId, b.stepId, 'working')), TINY_PNG);
});
test('duplicate guide produces independent deep copy with fresh ids', (t) => { test('duplicate guide produces independent deep copy with fresh ids', (t) => {
const root = makeTmpDir('dup'); const root = makeTmpDir('dup');
t.after(() => rmrf(root)); t.after(() => rmrf(root));
@@ -188,3 +213,27 @@ test('guide ids are validated against path traversal', (t) => {
assert.throws(() => store.guideDir('a/b')); assert.throws(() => store.guideDir('a/b'));
assert.throws(() => store.stepDir('ok', '..')); assert.throws(() => store.stepDir('ok', '..'));
}); });
test('guide metadata: optional fields default to empty strings and round-trip via save/load', (t) => {
const root = makeTmpDir('metadata');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'Metadata test' });
assert.deepEqual(guide.metadata, { author: '', coAuthors: '', organization: '' });
guide.metadata = { author: 'Jane Doe', coAuthors: 'Alex Lee, Sam Patel', organization: 'Acme Corp' };
store.saveGuide(guide);
const fresh = new GuideStore(root);
const loaded = fresh.getGuide(guide.guideId);
assert.deepEqual(loaded.metadata, { author: 'Jane Doe', coAuthors: 'Alex Lee, Sam Patel', organization: 'Acme Corp' });
// A guide saved before metadata existed normalizes to the same defaults.
const legacy = fresh.getGuide(guide.guideId);
delete legacy.metadata;
store.saveGuide(legacy);
assert.deepEqual(fresh.getGuide(guide.guideId).metadata, { author: '', coAuthors: '', organization: '' });
loaded.metadata = 'not an object';
assert.throws(() => store.saveGuide(loaded), /metadata must be an object/);
});
+464
View File
@@ -0,0 +1,464 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const assert = require('node:assert/strict');
const { makeTmpDir, rmrf } = require('./helpers');
const { createStep } = require('../../core/schema');
const {
buildCaptureTitle,
buildAiPrompt,
normalizeAiPatch,
applyAiPatchToStep,
} = require('../../core/text-intel');
const { TextIntelService } = require('../../app/text-intel');
function makeSettings(values = {}) {
const data = {
ai: {
enabled: true,
ollama: {
host: 'http://127.0.0.1:11434',
model: 'llama3.2:1b',
},
},
...values,
};
return {
get(key) {
return key.split('.').reduce((acc, part) => (acc == null ? undefined : acc[part]), data);
},
};
}
test('capture titles prefer semantic metadata before OCR fallback', () => {
const title = buildCaptureTitle({
mode: 'fullscreen',
metadata: { windowTitle: 'Reset user password in admin portal' },
ocrText: 'Save',
});
assert.equal(title, 'Click Save');
});
test('capture titles prefer element metadata before window chrome and OCR', () => {
const title = buildCaptureTitle({
mode: 'window',
metadata: {
elementLabel: 'Open advanced settings',
windowTitle: 'Preferences',
},
ocrText: 'Cancel',
});
assert.equal(title, 'Open advanced settings');
});
test('capture titles ignore browser chrome noise in favor of OCR', () => {
const title = buildCaptureTitle({
mode: 'window',
metadata: {
windowTitle: 'Google Chrome ** PR reviews ** /chrome/tyler/autodoc',
appName: 'Google Chrome',
},
ocrText: 'New tab',
});
// OCR wins over the noisy window title; app name is appended for context.
assert.equal(title, 'Click New tab in Google Chrome');
});
test('tab-like roles use select when OCR identifies a tab label', () => {
const title = buildCaptureTitle({
mode: 'window',
metadata: {
elementLabel: 'New tab',
elementRole: 'tab item',
windowTitle: 'Google Chrome - PR reviews',
},
ocrText: 'New tab',
});
assert.equal(title, 'Select New tab');
});
test('capture titles fall back to OCR when metadata is absent', () => {
const title = buildCaptureTitle({
mode: 'window',
metadata: {},
ocrText: 'Save changes',
});
assert.equal(title, 'Click Save changes');
});
test('browser window title strips browser name and falls back to page title', () => {
// OCR fails; browser window title should give something useful, not "Screen capture".
const title = buildCaptureTitle({
mode: 'fullscreen',
metadata: {
windowTitle: 'Oracle | Cloud Applications and Cloud Platform - Google Chrome',
appName: 'chrome',
},
ocrText: '',
});
// Stripped title "Oracle | Cloud Applications and Cloud Platform" → best fragment
assert.ok(title !== 'Screen capture', `Expected smart title, got: ${title}`);
assert.ok(title.toLowerCase().includes('oracle') || title.toLowerCase().includes('cloud'), `Expected oracle/cloud in title, got: ${title}`);
});
test('search query is extracted when user was typing (search step)', () => {
const title = buildCaptureTitle({
mode: 'fullscreen',
metadata: { windowTitle: 'oracle - Google Search - Google Chrome', appName: 'chrome' },
ocrText: '',
recentTyped: 'oracle', // user was typing → this IS the search step
});
assert.equal(title, 'Search for Oracle in Chrome');
});
test('search results window title produces select-result title when no typing (click on results page)', () => {
const title = buildCaptureTitle({
mode: 'fullscreen',
metadata: { windowTitle: 'oracle - Google Search - Google Chrome', appName: 'chrome' },
ocrText: '',
recentTyped: '', // no recent typing → user is clicking a result, not searching
});
assert.equal(title, 'Select a Oracle result in Chrome');
});
test('full link text with pipe separator is preserved in OCR phrases', () => {
const title = buildCaptureTitle({
mode: 'fullscreen',
metadata: { elementRole: 'hyperlink' },
ocrText: 'Oracle | Cloud Applications and Cloud Platform',
});
assert.equal(title, 'Select Oracle | Cloud Applications and Cloud Platform');
});
test('link element role uses Select verb', () => {
const title = buildCaptureTitle({
mode: 'fullscreen',
metadata: { elementLabel: 'Sign in', elementRole: 'hyperlink' },
ocrText: '',
});
assert.equal(title, 'Select Sign in');
});
test('search box element role uses Search for verb', () => {
const title = buildCaptureTitle({
mode: 'fullscreen',
metadata: { elementLabel: 'oracle', elementRole: 'search box' },
ocrText: '',
});
assert.equal(title, 'Search for Oracle');
});
test('keyboard shortcut produces action title qualified with app', () => {
const title = buildCaptureTitle({
mode: 'fullscreen',
metadata: { appName: 'chrome' },
ocrText: '',
recentShortcut: 'Ctrl+T',
});
assert.equal(title, 'Open new tab in Chrome');
});
test('keyboard shortcut title without app name', () => {
const title = buildCaptureTitle({
mode: 'fullscreen',
metadata: {},
ocrText: '',
recentShortcut: 'Ctrl+S',
});
assert.equal(title, 'Save');
});
test('typed text with search input role produces Search for title', () => {
const title = buildCaptureTitle({
mode: 'fullscreen',
metadata: { elementRole: 'search box', appName: 'chrome' },
ocrText: '',
recentTyped: 'oracle',
});
assert.equal(title, 'Search for "oracle" in Chrome');
});
test('UIAutomation element value takes priority over keyboard buffer', () => {
const title = buildCaptureTitle({
mode: 'fullscreen',
metadata: { elementRole: 'edit', elementValue: 'oracle', appName: 'chrome' },
ocrText: '',
recentTyped: 'ignored',
});
// elementValue (from UIAutomation) wins over the keyboard buffer
assert.ok(title.includes('oracle'), `expected oracle in title, got: ${title}`);
});
test('app-qualified OCR title includes app name', () => {
const title = buildCaptureTitle({
mode: 'fullscreen',
metadata: { appName: 'code' },
ocrText: 'Save',
});
assert.equal(title, 'Click Save in VS Code');
});
test('ai prompts include the deterministic OCR-backed title candidate', () => {
const { prompt } = buildAiPrompt({
captureContext: {
windowTitle: 'Google Chrome ** PR reviews ** /chrome/tyler/autodoc',
appName: 'Google Chrome',
ocrText: 'New tab',
titleCandidate: 'Click New tab',
mode: 'content',
},
});
assert.match(prompt, /Suggested title: Click New tab/);
});
test('ocr crop rectangles clamp to the image bounds', (t) => {
const root = makeTmpDir('text-intel-crop');
t.after(() => rmrf(root));
const service = new TextIntelService({
store: { settingsDir: root },
settings: makeSettings(),
getWindow: () => null,
dataDir: root,
fetchImpl: global.fetch,
});
const frame = {
size: { width: 1000, height: 500 },
display: { bounds: { x: 0, y: 0, width: 1000, height: 500 } },
};
const topLeft = service.cropRectForPoint(frame, { x: 5, y: 5 });
assert.deepEqual(topLeft, { x: 0, y: 0, width: 420, height: 220 });
const bottomRight = service.cropRectForPoint(frame, { x: 995, y: 495 });
assert.deepEqual(bottomRight, { x: 580, y: 280, width: 420, height: 220 });
});
test('ocr failures fall back to empty text instead of crashing', async (t) => {
const root = makeTmpDir('text-intel-ocr-fallback');
t.after(() => rmrf(root));
const service = new TextIntelService({
store: { settingsDir: root },
settings: makeSettings(),
getWindow: () => null,
dataDir: root,
fetchImpl: global.fetch,
});
service.getWorker = async () => {
throw new Error('tesseract missing');
};
const result = await service.ocrAroundClick({
image: {},
size: { width: 100, height: 100 },
display: { bounds: { x: 0, y: 0, width: 100, height: 100 } },
}, { x: 50, y: 50 });
assert.deepEqual(result, { text: '', confidence: null });
});
test('ai response normalization and application keeps fields structured', () => {
const patch = normalizeAiPatch(JSON.stringify({
title: 'Open settings',
description: 'Pick the AI tab.',
blocks: [
{
kind: 'text',
position: 'after-description',
level: 'tip',
title: 'Tip',
body: 'Use the local Ollama model.',
},
{
kind: 'code',
language: 'bash',
code: 'ollama pull llama3.2:1b',
},
{
kind: 'table',
rows: [['Name', 'Value'], ['Host', '127.0.0.1']],
},
],
}));
const step = createStep({
title: 'Old title',
descriptionHtml: '<p>Old text</p>',
textBlocks: [{ id: 'tb1', order: 1, position: 'after-description', level: 'info', title: 'Old tip', descriptionHtml: '<p>Old body</p>' }],
codeBlocks: [{ id: 'cb1', order: 2, language: 'text', code: 'old' }],
tableBlocks: [{ id: 'tbl1', order: 3, rows: [['x']] }],
});
const updated = applyAiPatchToStep(step, patch, { target: 'all' });
assert.equal(updated.title, 'Open settings');
assert.equal(updated.descriptionHtml, '<p>Pick the AI tab.</p>');
assert.equal(updated.textBlocks.length, 1);
assert.equal(updated.textBlocks[0].level, 'success');
assert.equal(updated.textBlocks[0].descriptionHtml, '<p>Use the local Ollama model.</p>');
assert.equal(updated.codeBlocks[0].code, 'ollama pull llama3.2:1b');
assert.deepEqual(updated.tableBlocks[0].rows, [['Name', 'Value'], ['Host', '127.0.0.1']]);
});
test('ollama connection test reports installed models', async (t) => {
const root = makeTmpDir('text-intel-ai');
t.after(() => rmrf(root));
const service = new TextIntelService({
store: { settingsDir: root },
settings: makeSettings(),
getWindow: () => null,
dataDir: root,
fetchImpl: async (url) => {
const pathname = new URL(url).pathname;
if (pathname === '/api/tags') {
return {
ok: true,
json: async () => ({
models: [
{ name: 'llama3.2:1b' },
{ name: 'qwen3:0.6b' },
],
}),
};
}
if (pathname === '/api/show') {
return {
ok: true,
json: async () => ({
capabilities: ['completion', 'vision'],
}),
};
}
throw new Error(`unexpected fetch: ${pathname}`);
},
});
const result = await service.testAiConnection();
assert.equal(result.ok, true);
assert.equal(result.installed, true);
assert.equal(result.model, 'llama3.2:1b');
assert.equal(result.vision, true);
});
test('vision-capable models receive the screenshot in the chat request', async (t) => {
const root = makeTmpDir('text-intel-ai-vision');
t.after(() => rmrf(root));
const imagePath = path.join(root, 'step.png');
fs.writeFileSync(imagePath, Buffer.from('fake screenshot bytes'));
const step = createStep({
title: 'Old title',
descriptionHtml: '<p>Old text</p>',
image: {
originalPath: 'original.png',
workingPath: 'working.png',
size: { width: 10, height: 10 },
},
captureMetadata: {
windowTitle: 'Settings',
appName: 'chrome',
ocrText: 'Open settings',
titleCandidate: 'Open settings',
mode: 'fullscreen',
},
});
const fetchCalls = [];
const service = new TextIntelService({
store: {
settingsDir: root,
getGuide: () => ({ guideId: 'g1', title: 'Guide', descriptionHtml: '', stepsOrder: ['s1'] }),
getStep: () => step,
stepImagePath: () => imagePath,
saveStep: (_, next) => next,
},
settings: makeSettings(),
getWindow: () => null,
dataDir: root,
fetchImpl: async (url, init = {}) => {
const pathname = new URL(url).pathname;
fetchCalls.push({ pathname, init });
if (pathname === '/api/show') {
return {
ok: true,
json: async () => ({
capabilities: ['completion', 'vision'],
}),
};
}
if (pathname === '/api/chat') {
return {
ok: true,
json: async () => ({
message: {
content: JSON.stringify({
title: 'Open settings',
description: 'Use the AI tab.',
}),
},
}),
};
}
throw new Error(`unexpected fetch: ${pathname}`);
},
});
const result = await service.generateStepPatch({
guideId: 'g1',
stepId: 's1',
target: 'all',
});
assert.equal(result.ok, true);
const chatCall = fetchCalls.find((call) => call.pathname === '/api/chat');
assert.ok(chatCall, 'expected an Ollama chat request');
const body = JSON.parse(chatCall.init.body);
assert.deepEqual(body.messages[1].images, [fs.readFileSync(imagePath).toString('base64')]);
assert.match(body.messages[1].content, /Screenshot: attached/i);
});
test('invalid ollama output fails safely without saving the step', async (t) => {
const root = makeTmpDir('text-intel-ai-invalid');
t.after(() => rmrf(root));
let saveCalls = 0;
const step = createStep({
title: 'Old title',
descriptionHtml: '<p>Old text</p>',
});
const service = new TextIntelService({
store: {
settingsDir: root,
getGuide: () => ({ guideId: 'g1', title: 'Guide', descriptionHtml: '', stepsOrder: ['s1'] }),
getStep: () => step,
stepImagePath: () => null,
saveStep: () => {
saveCalls += 1;
throw new Error('save should not be called');
},
},
settings: makeSettings(),
getWindow: () => null,
dataDir: root,
fetchImpl: async () => ({
ok: true,
json: async () => ({
message: {
content: 'not json at all',
},
}),
}),
});
const result = await service.generateStepPatch({
guideId: 'g1',
stepId: 's1',
target: 'all',
});
assert.equal(result.ok, false);
assert.match(result.reason, /JSON/i);
assert.equal(saveCalls, 0);
assert.equal(step.title, 'Old title');
});