gutterpress 0.10.1-beta.1 → 0.10.1-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/dist/api/index.js +2 -2
  2. package/dist/{audit-7a37g51p.js → audit-sse9r5rm.js} +4 -4
  3. package/dist/{build-kdaet3pd.js → build-9dn6r6ak.js} +4 -4
  4. package/dist/{cli-ak1tagkr.js → cli-eewynen9.js} +3 -3
  5. package/dist/{cli-dx6sxj2t.js → cli-jhvkcwvf.js} +1 -1
  6. package/dist/{cli-mcb484g3.js → cli-qxebvmrq.js} +1 -1
  7. package/dist/{cli-df7gcyns.js → cli-wtyzagaq.js} +4 -4
  8. package/dist/cli.js +14 -14
  9. package/dist/{doctor-vajd5h45.js → doctor-pswwk1nm.js} +2 -2
  10. package/dist/{engine-gkcqdye5.js → engine-57vw8zpd.js} +1 -1
  11. package/dist/{engine-0nt35svz.js → engine-8kdwy0jz.js} +2 -2
  12. package/dist/{index-6g72h2n1.js → index-7faw7w8v.js} +3 -3
  13. package/dist/{index-b2rqw41a.js → index-7hrvksrb.js} +3 -3
  14. package/dist/{index-kvgq4q3q.js → index-p0ebr7dh.js} +1 -1
  15. package/dist/index.js +3 -3
  16. package/dist/{lint-p6a4bkvh.js → lint-bbc9qk1h.js} +4 -4
  17. package/dist/{new-4cdzfbsj.js → new-aj8be9p9.js} +4 -4
  18. package/dist/{plugin-a89tqspy.js → plugin-raq1arqq.js} +4 -4
  19. package/dist/{preflight-r4y3f8f9.js → preflight-hq060mas.js} +4 -4
  20. package/dist/{preview-bridge-fz7vpk8m.js → preview-bridge-2nnrs2hy.js} +15 -0
  21. package/dist/{preview-k8n2kew7.js → preview-c4d698wn.js} +4 -4
  22. package/dist/{preview-interface-0ssk8bmm.js → preview-interface-2f5v96hd.js} +411 -88
  23. package/dist/{preview-shell-c5mfa3q0.js → preview-shell-kyjq59d9.js} +80 -25
  24. package/dist/{publish-jnhjp2vd.js → publish-ft5x1e5v.js} +4 -4
  25. package/dist/schema/manifest.types.d.ts +6 -3
  26. package/dist/{validate-prnybpkx.js → validate-wp7rgg1y.js} +4 -4
  27. package/package.json +1 -1
package/dist/api/index.js CHANGED
@@ -95,12 +95,12 @@ import {
95
95
  syncProject,
96
96
  testRemoteAccess,
97
97
  validateProjectPlugins
98
- } from "../index-6g72h2n1.js";
98
+ } from "../index-7faw7w8v.js";
99
99
  import {
100
100
  BuildError,
101
101
  DEFAULT_PRINT_OPTS,
102
102
  readyProbeExpr
103
- } from "../index-b2rqw41a.js";
103
+ } from "../index-7hrvksrb.js";
104
104
  import {
105
105
  AUTO_SNAPSHOT_MESSAGE,
106
106
  HISTORY_PAGE_LIMIT,
@@ -1,15 +1,15 @@
1
1
  import {
2
2
  executeAndReport
3
- } from "./cli-df7gcyns.js";
3
+ } from "./cli-wtyzagaq.js";
4
4
  import {
5
5
  log
6
- } from "./cli-mcb484g3.js";
6
+ } from "./cli-qxebvmrq.js";
7
7
  import {
8
8
  UsageError,
9
9
  rejectExtraPositionals,
10
10
  rejectUnknownFlags
11
- } from "./cli-dx6sxj2t.js";
12
- import"./cli-ak1tagkr.js";
11
+ } from "./cli-jhvkcwvf.js";
12
+ import"./cli-eewynen9.js";
13
13
  import"./cli-c41yr7he.js";
14
14
  import {
15
15
  EXIT_CODES
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  runBuild,
3
3
  splitOutPath
4
- } from "./cli-df7gcyns.js";
4
+ } from "./cli-wtyzagaq.js";
5
5
  import {
6
6
  log
7
- } from "./cli-mcb484g3.js";
7
+ } from "./cli-qxebvmrq.js";
8
8
  import {
9
9
  UsageError,
10
10
  parseEngine,
@@ -12,8 +12,8 @@ import {
12
12
  parsePdfxFlavor,
13
13
  rejectExtraPositionals,
14
14
  rejectUnknownFlags
15
- } from "./cli-dx6sxj2t.js";
16
- import"./cli-ak1tagkr.js";
15
+ } from "./cli-jhvkcwvf.js";
16
+ import"./cli-eewynen9.js";
17
17
  import"./cli-c41yr7he.js";
18
18
  import {
19
19
  BuildError
@@ -1422,13 +1422,13 @@ var favicon_default = "./favicon-wkbm9cjn.ico";
1422
1422
  var manifest_schema_default = "./manifest.schema-zxgxnbg7.json";
1423
1423
 
1424
1424
  // src/assets/preview/scripts/preview-interface.js
1425
- var preview_interface_default = "./preview-interface-0ssk8bmm.js";
1425
+ var preview_interface_default = "./preview-interface-2f5v96hd.js";
1426
1426
 
1427
1427
  // src/assets/preview/scripts/preview-bridge.js
1428
- var preview_bridge_default = "./preview-bridge-fz7vpk8m.js";
1428
+ var preview_bridge_default = "./preview-bridge-2nnrs2hy.js";
1429
1429
 
1430
1430
  // src/assets/preview/scripts/preview-shell.js
1431
- var preview_shell_default = "./preview-shell-c5mfa3q0.js";
1431
+ var preview_shell_default = "./preview-shell-kyjq59d9.js";
1432
1432
 
1433
1433
  // profiles/CGATS21_CRPC1.icc
1434
1434
  var CGATS21_CRPC1_default = "./CGATS21_CRPC1-g0e3k7kr.icc";
@@ -4,7 +4,7 @@ import {
4
4
  // package.json
5
5
  var package_default = {
6
6
  name: "gutterpress",
7
- version: "0.10.1-beta.1",
7
+ version: "0.10.1-beta.3",
8
8
  description: "Markdown-to-PDF converter for professional print layout using a native Chromium print engine and Ghostscript.",
9
9
  author: "itlackey",
10
10
  license: "MPL-2.0",
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  package_default
3
- } from "./cli-dx6sxj2t.js";
3
+ } from "./cli-jhvkcwvf.js";
4
4
  import {
5
5
  execCapture,
6
6
  findTool,
@@ -13,12 +13,12 @@ import {
13
13
  stampCreator,
14
14
  stripAnnotations,
15
15
  warn
16
- } from "./cli-mcb484g3.js";
16
+ } from "./cli-qxebvmrq.js";
17
17
  import {
18
18
  DEBOUNCE,
19
19
  UsageError,
20
20
  resolvePort
21
- } from "./cli-dx6sxj2t.js";
21
+ } from "./cli-jhvkcwvf.js";
22
22
  import {
23
23
  MARGIN_BOX_IGNORED_PROPERTIES,
24
24
  RENDER_TIMEOUT_MS,
@@ -27,7 +27,7 @@ import {
27
27
  getAssetPath,
28
28
  getBrowser,
29
29
  prewarmBrowser
30
- } from "./cli-ak1tagkr.js";
30
+ } from "./cli-eewynen9.js";
31
31
  import {
32
32
  INSTALL_HINTS,
33
33
  execCapture,
@@ -7521,7 +7521,7 @@ class PdfOutput {
7521
7521
  const rawPdf = pdfxMode ? path8.join(stage, "raw.pdf") : path8.resolve(pdfFile);
7522
7522
  await fsp2.mkdir(path8.dirname(path8.resolve(pdfFile)), { recursive: true });
7523
7523
  log.info("Rendering HTML to PDF via the Gutterpress engine (native Chromium pagination)");
7524
- const { buildNativePdf } = await import("./engine-gkcqdye5.js");
7524
+ const { buildNativePdf } = await import("./engine-57vw8zpd.js");
7525
7525
  const engineDiagnostics = await buildNativePdf(htmlFile, rawPdf, {
7526
7526
  title: config.title,
7527
7527
  author: config.authors.length > 0 ? config.authors.join(", ") : undefined,
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  UsageError,
7
7
  package_default,
8
8
  rejectUnknownFlags
9
- } from "./cli-dx6sxj2t.js";
9
+ } from "./cli-jhvkcwvf.js";
10
10
  import {
11
11
  EXIT_CODES
12
12
  } from "./cli-46ycxe6r.js";
@@ -18,16 +18,16 @@ import {
18
18
  import { defineCommand, parseArgs, runMain } from "citty";
19
19
  import { statSync } from "node:fs";
20
20
  var SUBCOMMANDS = {
21
- new: () => import("./new-4cdzfbsj.js").then((m) => m.default),
22
- preview: () => import("./preview-k8n2kew7.js").then((m) => m.default),
23
- build: () => import("./build-kdaet3pd.js").then((m) => m.default),
24
- publish: () => import("./publish-jnhjp2vd.js").then((m) => m.default),
25
- lint: () => import("./lint-p6a4bkvh.js").then((m) => m.default),
26
- validate: () => import("./validate-prnybpkx.js").then((m) => m.default),
27
- audit: () => import("./audit-7a37g51p.js").then((m) => m.default),
28
- preflight: () => import("./preflight-r4y3f8f9.js").then((m) => m.default),
29
- doctor: () => import("./doctor-vajd5h45.js").then((m) => m.default),
30
- plugin: () => import("./plugin-a89tqspy.js").then((m) => m.default)
21
+ new: () => import("./new-aj8be9p9.js").then((m) => m.default),
22
+ preview: () => import("./preview-c4d698wn.js").then((m) => m.default),
23
+ build: () => import("./build-9dn6r6ak.js").then((m) => m.default),
24
+ publish: () => import("./publish-ft5x1e5v.js").then((m) => m.default),
25
+ lint: () => import("./lint-bbc9qk1h.js").then((m) => m.default),
26
+ validate: () => import("./validate-wp7rgg1y.js").then((m) => m.default),
27
+ audit: () => import("./audit-sse9r5rm.js").then((m) => m.default),
28
+ preflight: () => import("./preflight-hq060mas.js").then((m) => m.default),
29
+ doctor: () => import("./doctor-pswwk1nm.js").then((m) => m.default),
30
+ plugin: () => import("./plugin-raq1arqq.js").then((m) => m.default)
31
31
  };
32
32
  var VERSION = package_default.version;
33
33
  var main = defineCommand({
@@ -52,7 +52,7 @@ async function preflightRequiredInvocations(rawArgs) {
52
52
  const [command, ...commandArgs] = rawArgs;
53
53
  try {
54
54
  if (command === "new") {
55
- const { newArgs } = await import("./new-4cdzfbsj.js");
55
+ const { newArgs } = await import("./new-aj8be9p9.js");
56
56
  rejectUnknownFlags(commandArgs, newArgs, "new");
57
57
  const parsed2 = parseArgs(commandArgs, {
58
58
  ...newArgs,
@@ -64,7 +64,7 @@ async function preflightRequiredInvocations(rawArgs) {
64
64
  return;
65
65
  }
66
66
  if (command === "preflight") {
67
- const { preflightArgs } = await import("./preflight-r4y3f8f9.js");
67
+ const { preflightArgs } = await import("./preflight-hq060mas.js");
68
68
  rejectUnknownFlags(commandArgs, preflightArgs, "preflight");
69
69
  const parsed2 = parseArgs(commandArgs, {
70
70
  ...preflightArgs,
@@ -88,7 +88,7 @@ async function preflightRequiredInvocations(rawArgs) {
88
88
  if (subcommand !== "add") {
89
89
  throw new UsageError(`gutterpress plugin: unknown command "${subcommand}"`);
90
90
  }
91
- const { pluginAddArgs } = await import("./plugin-a89tqspy.js");
91
+ const { pluginAddArgs } = await import("./plugin-raq1arqq.js");
92
92
  rejectUnknownFlags(subcommandArgs, pluginAddArgs, "plugin add");
93
93
  const parsed = parseArgs(subcommandArgs, {
94
94
  ...pluginAddArgs,
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  getSystemDiagnostics,
3
3
  log
4
- } from "./cli-mcb484g3.js";
4
+ } from "./cli-qxebvmrq.js";
5
5
  import {
6
6
  UsageError,
7
7
  rejectExtraPositionals,
8
8
  rejectUnknownFlags
9
- } from "./cli-dx6sxj2t.js";
9
+ } from "./cli-jhvkcwvf.js";
10
10
  import"./cli-c41yr7he.js";
11
11
  import"./cli-46ycxe6r.js";
12
12
  import"./cli-37x76zdn.js";
@@ -4,7 +4,7 @@ import {
4
4
  build,
5
5
  connectChromium,
6
6
  getBrowser
7
- } from "./cli-ak1tagkr.js";
7
+ } from "./cli-eewynen9.js";
8
8
  import"./cli-c41yr7he.js";
9
9
  import {
10
10
  BuildError
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  build
3
- } from "./index-kvgq4q3q.js";
3
+ } from "./index-p0ebr7dh.js";
4
4
  import {
5
5
  BuildError,
6
6
  RENDER_TIMEOUT_MS,
7
7
  assertMilestone,
8
8
  connectChromium,
9
9
  getBrowser
10
- } from "./index-b2rqw41a.js";
10
+ } from "./index-7hrvksrb.js";
11
11
  import"./index-37x76zdn.js";
12
12
 
13
13
  // src/lib/engine.ts
@@ -17,7 +17,7 @@ import {
17
17
  resolveChromiumExecutable,
18
18
  run,
19
19
  spawnCapture
20
- } from "./index-b2rqw41a.js";
20
+ } from "./index-7hrvksrb.js";
21
21
  import {
22
22
  gitFs,
23
23
  gitScopeFor,
@@ -3980,7 +3980,7 @@ import git from "isomorphic-git";
3980
3980
  // package.json
3981
3981
  var package_default = {
3982
3982
  name: "gutterpress",
3983
- version: "0.10.1-beta.1",
3983
+ version: "0.10.1-beta.3",
3984
3984
  description: "Markdown-to-PDF converter for professional print layout using a native Chromium print engine and Ghostscript.",
3985
3985
  author: "itlackey",
3986
3986
  license: "MPL-2.0",
@@ -8016,7 +8016,7 @@ class PdfOutput {
8016
8016
  const rawPdf = pdfxMode ? path8.join(stage, "raw.pdf") : path8.resolve(pdfFile);
8017
8017
  await fsp2.mkdir(path8.dirname(path8.resolve(pdfFile)), { recursive: true });
8018
8018
  log.info("Rendering HTML to PDF via the Gutterpress engine (native Chromium pagination)");
8019
- const { buildNativePdf } = await import("./engine-0nt35svz.js");
8019
+ const { buildNativePdf } = await import("./engine-8kdwy0jz.js");
8020
8020
  const engineDiagnostics = await buildNativePdf(htmlFile, rawPdf, {
8021
8021
  title: config.title,
8022
8022
  author: config.authors.length > 0 ? config.authors.join(", ") : undefined,
@@ -596,13 +596,13 @@ var favicon_default = "./favicon-wkbm9cjn.ico";
596
596
  var manifest_schema_default = "./manifest.schema-zxgxnbg7.json";
597
597
 
598
598
  // src/assets/preview/scripts/preview-interface.js
599
- var preview_interface_default = "./preview-interface-0ssk8bmm.js";
599
+ var preview_interface_default = "./preview-interface-2f5v96hd.js";
600
600
 
601
601
  // src/assets/preview/scripts/preview-bridge.js
602
- var preview_bridge_default = "./preview-bridge-fz7vpk8m.js";
602
+ var preview_bridge_default = "./preview-bridge-2nnrs2hy.js";
603
603
 
604
604
  // src/assets/preview/scripts/preview-shell.js
605
- var preview_shell_default = "./preview-shell-c5mfa3q0.js";
605
+ var preview_shell_default = "./preview-shell-kyjq59d9.js";
606
606
 
607
607
  // profiles/CGATS21_CRPC1.icc
608
608
  var CGATS21_CRPC1_default = "./CGATS21_CRPC1-g0e3k7kr.icc";
@@ -4,7 +4,7 @@ import {
4
4
  launchChromium,
5
5
  marginBoxAlign,
6
6
  marginBoxRectPt
7
- } from "./index-b2rqw41a.js";
7
+ } from "./index-7hrvksrb.js";
8
8
 
9
9
  // src/engine/compiler/build.ts
10
10
  import { readFile } from "node:fs/promises";
package/dist/index.js CHANGED
@@ -113,15 +113,15 @@ import {
113
113
  syncProject,
114
114
  testRemoteAccess,
115
115
  validateProjectPlugins
116
- } from "./index-6g72h2n1.js";
116
+ } from "./index-7faw7w8v.js";
117
117
  import {
118
118
  BUILD_DIAGNOSTIC_CODES
119
- } from "./index-kvgq4q3q.js";
119
+ } from "./index-p0ebr7dh.js";
120
120
  import {
121
121
  BuildError,
122
122
  DEFAULT_PRINT_OPTS,
123
123
  readyProbeExpr
124
- } from "./index-b2rqw41a.js";
124
+ } from "./index-7hrvksrb.js";
125
125
  import {
126
126
  AUTO_SNAPSHOT_MESSAGE,
127
127
  HISTORY_PAGE_LIMIT,
@@ -1,16 +1,16 @@
1
1
  import {
2
2
  MANIFEST_FILENAMES,
3
3
  runLint
4
- } from "./cli-df7gcyns.js";
4
+ } from "./cli-wtyzagaq.js";
5
5
  import {
6
6
  log
7
- } from "./cli-mcb484g3.js";
7
+ } from "./cli-qxebvmrq.js";
8
8
  import {
9
9
  UsageError,
10
10
  rejectExtraPositionals,
11
11
  rejectUnknownFlags
12
- } from "./cli-dx6sxj2t.js";
13
- import"./cli-ak1tagkr.js";
12
+ } from "./cli-jhvkcwvf.js";
13
+ import"./cli-eewynen9.js";
14
14
  import"./cli-c41yr7he.js";
15
15
  import {
16
16
  EXIT_CODES
@@ -5,16 +5,16 @@ import {
5
5
  TARGETS,
6
6
  TARGET_IDS,
7
7
  scaffoldProject
8
- } from "./cli-df7gcyns.js";
8
+ } from "./cli-wtyzagaq.js";
9
9
  import {
10
10
  resolveGhostscript
11
- } from "./cli-mcb484g3.js";
11
+ } from "./cli-qxebvmrq.js";
12
12
  import {
13
13
  UsageError,
14
14
  rejectExtraPositionals,
15
15
  rejectUnknownFlags
16
- } from "./cli-dx6sxj2t.js";
17
- import"./cli-ak1tagkr.js";
16
+ } from "./cli-jhvkcwvf.js";
17
+ import"./cli-eewynen9.js";
18
18
  import {
19
19
  isToolAvailable
20
20
  } from "./cli-c41yr7he.js";
@@ -1,14 +1,14 @@
1
1
  import {
2
2
  addNpmPlugin,
3
3
  parseNpmPluginSpec
4
- } from "./cli-df7gcyns.js";
5
- import"./cli-mcb484g3.js";
4
+ } from "./cli-wtyzagaq.js";
5
+ import"./cli-qxebvmrq.js";
6
6
  import {
7
7
  UsageError,
8
8
  rejectExtraPositionals,
9
9
  rejectUnknownFlags
10
- } from "./cli-dx6sxj2t.js";
11
- import"./cli-ak1tagkr.js";
10
+ } from "./cli-jhvkcwvf.js";
11
+ import"./cli-eewynen9.js";
12
12
  import"./cli-c41yr7he.js";
13
13
  import {
14
14
  EXIT_CODES
@@ -2,16 +2,16 @@ import {
2
2
  executeValidation,
3
3
  publishTargetFor,
4
4
  reportMissingTools
5
- } from "./cli-df7gcyns.js";
5
+ } from "./cli-wtyzagaq.js";
6
6
  import {
7
7
  log
8
- } from "./cli-mcb484g3.js";
8
+ } from "./cli-qxebvmrq.js";
9
9
  import {
10
10
  UsageError,
11
11
  rejectExtraPositionals,
12
12
  rejectUnknownFlags
13
- } from "./cli-dx6sxj2t.js";
14
- import"./cli-ak1tagkr.js";
13
+ } from "./cli-jhvkcwvf.js";
14
+ import"./cli-eewynen9.js";
15
15
  import"./cli-c41yr7he.js";
16
16
  import {
17
17
  EXIT_CODES
@@ -82,6 +82,21 @@
82
82
  window.addEventListener('contextMenuRequested', function (e) {
83
83
  post({ type: 'gutterpress:event', name: 'contextMenuRequested', detail: e.detail });
84
84
  });
85
+ // In-flow block editing (protocol v8). `Requested` is the double-click entry
86
+ // point — the SPA answers it with a beginBlockEdit command. `Finished`
87
+ // carries the edited text back for an end the AUTHOR initiated inside the
88
+ // book (Escape / Cmd+Enter / blur), which the SPA cannot observe. `State`
89
+ // fires on every open and close, including SPA-initiated ones, and is what
90
+ // preview-shell.js holds hot-reload swaps on.
91
+ window.addEventListener('blockEditRequested', function (e) {
92
+ post({ type: 'gutterpress:event', name: 'blockEditRequested', detail: e.detail });
93
+ });
94
+ window.addEventListener('blockEditFinished', function (e) {
95
+ post({ type: 'gutterpress:event', name: 'blockEditFinished', detail: e.detail });
96
+ });
97
+ window.addEventListener('blockEditStateChanged', function (e) {
98
+ post({ type: 'gutterpress:event', name: 'blockEditStateChanged', detail: e.detail });
99
+ });
85
100
 
86
101
  // Announce readiness as soon as previewAPI is defined.
87
102
  function announceReady() {
@@ -6,10 +6,10 @@ import {
6
6
  runBuild,
7
7
  splitOutPath,
8
8
  startPreviewServer
9
- } from "./cli-df7gcyns.js";
9
+ } from "./cli-wtyzagaq.js";
10
10
  import {
11
11
  log
12
- } from "./cli-mcb484g3.js";
12
+ } from "./cli-qxebvmrq.js";
13
13
  import {
14
14
  UsageError,
15
15
  parseEngine,
@@ -18,8 +18,8 @@ import {
18
18
  rejectExtraPositionals,
19
19
  rejectUnknownFlags,
20
20
  resolvePort
21
- } from "./cli-dx6sxj2t.js";
22
- import"./cli-ak1tagkr.js";
21
+ } from "./cli-jhvkcwvf.js";
22
+ import"./cli-eewynen9.js";
23
23
  import"./cli-c41yr7he.js";
24
24
  import {
25
25
  BuildError
@@ -5,11 +5,11 @@
5
5
  // window.Gutterpress.pageOf(el) (0-based); it fires 'gp:layout' when its
6
6
  // pagination completes.
7
7
  //
8
- // The block-overlay fragment-grouping machinery below (blocksMatchingRange)
9
- // groups by `{chapter, range}` (data-source-range) the native viewer never
10
- // clones an element across pages (an element that visually spans pages is
11
- // still ONE element), so nativeRectsFor resolves rects straight off the
12
- // single matching element via getClientRects() + pageOf().
8
+ // Blocks are addressed by `{chapter, range}` (data-source-range) — see
9
+ // blocksMatchingRange below. The native viewer never clones an element across
10
+ // pages (an element that visually spans pages is still ONE element), so such a
11
+ // spec resolves to AT MOST ONE element. That is what lets in-flow editing put
12
+ // a single contenteditable on a block that spans a page break.
13
13
 
14
14
  (function () {
15
15
  'use strict';
@@ -625,15 +625,39 @@
625
625
  return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
626
626
  }
627
627
 
628
- // ── Block-overlay geometry + masking (protocol v6) ──────────────────────────
629
- // docs/inline-editing-plan.md §5.3 / ADR 0009. A block split across pages
630
- // exists as MULTIPLE DOM fragments that duplicate every data attribute
631
- // (§3.5's split-fragment gotcha applies here too) `data-source-range` is
632
- // duplicated onto every fragment identically, so `{chapter, range}` groups
633
- // them (protocol v5 dropped `data-ref` from the wire contract entirely: the
634
- // native viewer never mints one at all it never clones, so there is
635
- // nothing to give a shared identity to and a source range already
636
- // uniquely identifies one block).
628
+ // ── In-flow block editing (protocol v8) ────────────────────────────────────
629
+ // docs/inline-editing-plan.md §3.1 / ADR 0009 decision 4 (as revised).
630
+ //
631
+ // The block's OWN element becomes the editing surface: its rendered HTML is
632
+ // swapped for that block's markdown source under
633
+ // `contenteditable="plaintext-only"`, so the caret sits in the real page, in
634
+ // the book's own typography, and Chromium's fragmenter re-flows the pages
635
+ // around it as the author types. This replaced a floating CodeMirror panel
636
+ // positioned in host-SPA coordinates; `getRectsFor()`/`setEditMask()` existed
637
+ // ONLY to serve that panel and went with it (protocol v8).
638
+ //
639
+ // Three properties of the native viewer are what make this work, each
640
+ // spike-verified rather than assumed (plan §2) — under Paged.js none of them
641
+ // held, which is why the panel existed:
642
+ // 1. A block spanning a page break is ONE element with several client
643
+ // rects, so it takes ONE contenteditable and the caret crosses the
644
+ // break natively (ArrowDown walks into the next page).
645
+ // 2. `white-space: pre-wrap` + `plaintext-only` round-trips multi-line
646
+ // markdown EXACTLY through `textContent` (lists, tables, fences); Enter
647
+ // inserts a real "\n" rather than a <div>/<br>, and a rich-HTML paste
648
+ // is stripped to text by the browser. So there is no serializer and no
649
+ // sanitiser here — text in, text out.
650
+ // 3. `Gutterpress.refresh()` -> `relayout()` rebuilds the strips from
651
+ // scratch and re-measures, so a growing block gets a correct page
652
+ // count. This is MANDATORY, not cosmetic: `.gp-strip` is
653
+ // `column-fill: auto` and its `.gp-run` clips to the last measured page,
654
+ // so content that grows past that is silently invisible rather than
655
+ // overlapping (measured: a strip needing 2400px still clipped at its
656
+ // 900px run width). Never mutate this DOM without scheduling a refresh.
657
+ //
658
+ // Nothing here writes to disk. `endBlockEdit()` hands text back and the SPA
659
+ // decides whether it becomes a patch, behind the commit engine's clean-buffer
660
+ // gate (ADR 0009 decision 3, unchanged).
637
661
 
638
662
  function rangedBlocks() {
639
663
  return Array.from(document.querySelectorAll('[data-source-range]'));
@@ -644,6 +668,10 @@
644
668
  return rangedBlocks().filter(function (el) { return chapterOf(el) === chapter; });
645
669
  }
646
670
 
671
+ // The native viewer never clones, so a `{chapter, range}` spec resolves to AT
672
+ // MOST ONE element — this returns an array only because callers still guard
673
+ // on emptiness (a range that no longer matches anything: block deleted or
674
+ // moved since the target was captured).
647
675
  function blocksMatchingRange(chapter, range) {
648
676
  if (!range) return [];
649
677
  return rangedBlocksInChapter(chapter).filter(function (el) {
@@ -652,42 +680,287 @@
652
680
  });
653
681
  }
654
682
 
655
- // getRectsFor(): the viewer never clones, so a spec resolves to AT MOST ONE
656
- // element. Its fragment rects come straight from getClientRects() a block
657
- // can still visually span pages if the browser's own multicol layout breaks
658
- // it there. Resolve each rect against the sheet it actually intersects;
659
- // pageOf(el) can only identify the element's starting fragmentainer.
660
- function nativeRectsFor(spec) {
661
- spec = spec || {};
662
- var el = blocksMatchingRange(spec.chapter, spec.range)[0] || null;
663
- if (!el) return { rects: [] };
664
- var geometryEl = el.tagName && el.tagName.toLowerCase() === 'code' &&
665
- el.parentElement && el.parentElement.tagName.toLowerCase() === 'pre'
666
- ? el.parentElement
667
- : el;
668
- if (pages.length === 0) refreshPages();
669
- var fallbackPage = pageIndexOf(el);
670
- var raw = geometryEl.getClientRects ? Array.from(geometryEl.getClientRects()) : [];
671
- if (!raw.length) {
672
- var r0 = plainRect(geometryEl);
673
- raw = r0 ? [r0] : [];
683
+ // Re-pagination debounce while typing. Tuned by measurement, not taste:
684
+ // `relayout()` costs "the same order as mount (tens of ms on a real book)"
685
+ // per fragment.ts, so it cannot run per keystroke on a long book.
686
+ var EDIT_REFRESH_MS = 120;
687
+
688
+ // The one live edit, or null. At most one at a time — `beginBlockEdit`
689
+ // commits any predecessor before opening (an author double-clicking straight
690
+ // from one block to another must not silently drop the first edit).
691
+ var edit = null;
692
+ var editRefreshTimer = null;
693
+
694
+ function scheduleEditRefresh() {
695
+ if (editRefreshTimer !== null) clearTimeout(editRefreshTimer);
696
+ editRefreshTimer = setTimeout(function () {
697
+ editRefreshTimer = null;
698
+ repaginate();
699
+ }, EDIT_REFRESH_MS);
700
+ }
701
+
702
+ // Character offset of the caret within the edit box, or null when the caret
703
+ // is not in it. Text-space, not node-space, so it survives the box being
704
+ // re-parented and its text nodes being renormalized.
705
+ function caretOffset() {
706
+ if (!edit) return null;
707
+ var sel = typeof window.getSelection === 'function' ? window.getSelection() : null;
708
+ if (!sel || sel.rangeCount === 0) return null;
709
+ var r = sel.getRangeAt(0);
710
+ if (!edit.el.contains(r.startContainer)) return null;
711
+ var pre = document.createRange();
712
+ pre.selectNodeContents(edit.el);
713
+ pre.setEnd(r.startContainer, r.startOffset);
714
+ return pre.toString().length;
715
+ }
716
+
717
+ // Depth-first text nodes, by `nodeType === 3` rather than TreeWalker +
718
+ // NodeFilter: the preview scripts run in hosts that do not expose every DOM
719
+ // global (the iframe test harness evaluates this file with an explicit,
720
+ // small global list), and a caret helper is not worth a host requirement.
721
+ function textNodesIn(root) {
722
+ var out = [];
723
+ var stack = [root];
724
+ while (stack.length) {
725
+ var node = stack.pop();
726
+ if (node.nodeType === 3) { out.push(node); continue; }
727
+ for (var i = node.childNodes.length - 1; i >= 0; i--) stack.push(node.childNodes[i]);
674
728
  }
675
- var rects = raw.map(function (r) {
676
- var page = fallbackPage;
677
- var bestArea = 0;
678
- for (var i = 0; i < pages.length; i++) {
679
- var sheetRect = pages[i].getBoundingClientRect();
680
- var width = Math.min(r.right, sheetRect.right) - Math.max(r.left, sheetRect.left);
681
- var height = Math.min(r.bottom, sheetRect.bottom) - Math.max(r.top, sheetRect.top);
682
- var area = width > 0 && height > 0 ? width * height : 0;
683
- if (area > bestArea) {
684
- bestArea = area;
685
- page = i + 1;
686
- }
729
+ return out;
730
+ }
731
+
732
+ function restoreCaret(offset) {
733
+ if (!edit || offset === null || offset === undefined) return;
734
+ var nodes = textNodesIn(edit.el);
735
+ var seen = 0;
736
+ for (var n = 0; n < nodes.length; n++) {
737
+ var node = nodes[n];
738
+ var len = node.nodeValue ? node.nodeValue.length : 0;
739
+ if (seen + len >= offset) {
740
+ var range = document.createRange();
741
+ range.setStart(node, Math.max(0, Math.min(len, offset - seen)));
742
+ range.collapse(true);
743
+ var sel = typeof window.getSelection === 'function' ? window.getSelection() : null;
744
+ if (!sel) return;
745
+ try { sel.removeAllRanges(); sel.addRange(range); } catch (_e) { /* best effort */ }
746
+ return;
687
747
  }
688
- return { top: r.top, left: r.left, width: r.width, height: r.height, page: page };
748
+ seen += len;
749
+ }
750
+ // Offset past the end (the text shrank): land at the end.
751
+ var end = document.createRange();
752
+ end.selectNodeContents(edit.el);
753
+ end.collapse(false);
754
+ var sel2 = typeof window.getSelection === 'function' ? window.getSelection() : null;
755
+ if (!sel2) return;
756
+ try { sel2.removeAllRanges(); sel2.addRange(end); } catch (_e) { /* best effort */ }
757
+ }
758
+
759
+ function repaginate() {
760
+ if (!window.Gutterpress || typeof window.Gutterpress.refresh !== 'function') return;
761
+ // `relayout()` unwraps every strip and rebuilds it, which RE-PARENTS the
762
+ // edit box — and re-parenting a focused element drops both its focus and
763
+ // the selection. Unhandled, that means the caret dies on the first
764
+ // debounced refresh after the author starts typing and every keystroke
765
+ // after it goes nowhere. Capture in text space, restore after.
766
+ var offset = caretOffset();
767
+ try {
768
+ window.Gutterpress.refresh();
769
+ } catch (_e) {
770
+ // A refresh failure must not trap the author inside a broken edit; the
771
+ // authoritative render still arrives on commit.
772
+ }
773
+ // The strips are rebuilt, so cached sheet nodes are stale.
774
+ pages = [];
775
+ if (edit && offset !== null) {
776
+ try { edit.el.focus({ preventScroll: true }); } catch (_e2) { /* best effort */ }
777
+ restoreCaret(offset);
778
+ }
779
+ }
780
+
781
+ // Place the caret from the click point, on the SOURCE text now in the box.
782
+ // Approximate by construction (the point was measured against the RENDERED
783
+ // text) and that is fine — it puts the caret near what the author aimed at,
784
+ // which is the whole affordance. Falls back to end-of-block.
785
+ function placeCaret(el, point) {
786
+ var range = null;
787
+ if (point && typeof document.caretRangeFromPoint === 'function') {
788
+ try {
789
+ var hit = document.caretRangeFromPoint(point.x, point.y);
790
+ if (hit && el.contains(hit.startContainer)) range = hit;
791
+ } catch (_e) { /* fall through to end-of-block */ }
792
+ }
793
+ if (!range) {
794
+ range = document.createRange();
795
+ range.selectNodeContents(el);
796
+ range.collapse(false);
797
+ }
798
+ try {
799
+ var sel = typeof window.getSelection === 'function' ? window.getSelection() : null;
800
+ if (!sel) return;
801
+ sel.removeAllRanges();
802
+ sel.addRange(range);
803
+ } catch (_e) {
804
+ // Focus is already set, so the box is usable even if seating the caret
805
+ // failed (a detached selection, a host without Selection support). The
806
+ // author clicks once more; nothing is lost.
807
+ }
808
+ }
809
+
810
+ function finishEdit(commit, notify) {
811
+ if (!edit) return { ended: false, text: null, commit: false };
812
+ if (editRefreshTimer !== null) {
813
+ clearTimeout(editRefreshTimer);
814
+ editRefreshTimer = null;
815
+ }
816
+ var el = edit.el;
817
+ var text = el.textContent || '';
818
+ var done = { ended: true, text: text, commit: commit === true, chapter: edit.chapter, range: edit.range };
819
+
820
+ // Restore the rendered HTML on BOTH paths. On cancel it is the final
821
+ // state; on commit it avoids showing raw markdown for the ~500ms until the
822
+ // authoritative re-render swaps the frame.
823
+ el.removeAttribute('contenteditable');
824
+ if (edit.hadWhiteSpace) el.style.whiteSpace = edit.whiteSpace;
825
+ else el.style.removeProperty('white-space');
826
+ if (!el.getAttribute('style')) el.removeAttribute('style');
827
+ el.classList.remove('gutterpress-editing');
828
+ el.innerHTML = edit.html;
829
+ el.removeEventListener('input', onEditInput);
830
+ document.removeEventListener('mousedown', onDocumentPointerDown, true);
831
+ window.removeEventListener('focus', onWindowFocus);
832
+ edit = null;
833
+ repaginate();
834
+
835
+ if (notify) {
836
+ window.dispatchEvent(new CustomEvent('blockEditFinished', { detail: done }));
837
+ }
838
+ // ALWAYS emitted, on both paths, unlike blockEditFinished above — the
839
+ // shell holds hot-reload swaps on this and a missed close would freeze the
840
+ // preview until the next manual action.
841
+ window.dispatchEvent(new CustomEvent('blockEditStateChanged', { detail: { open: false } }));
842
+ return done;
843
+ }
844
+
845
+ function onEditInput() {
846
+ scheduleEditRefresh();
847
+ }
848
+
849
+ // "Clicked away" commits — driven by a POINTER PRESS in the book, never by
850
+ // `blur`.
851
+ //
852
+ // Blur was the obvious signal and it is the wrong one here. The open request
853
+ // arrives by postMessage from a click in the host SPA (the context menu's
854
+ // "Edit this block"), so this frame does not hold browsing-context focus when
855
+ // `startEdit` runs. Focusing the box then races the frame-focus transition:
856
+ // Chromium settles `activeElement` back to BODY and the element receives a
857
+ // blur it never earned. Measured end-to-end in the packaged app — the editor
858
+ // opened and closed 7ms later, committing content the author never saw, so
859
+ // both entry points looked like they did nothing at all. Guarding on
860
+ // `document.hasFocus()` does not separate the two cases either: a real click
861
+ // on another paragraph ALSO lands on BODY with the document focused.
862
+ //
863
+ // A pointer press outside the box is unambiguous and needs no focus
864
+ // bookkeeping. Focus leaving the frame entirely (the author reaches for the
865
+ // editor pane or another window) is deliberately NOT a commit — the host
866
+ // drives those ends explicitly through `endBlockEdit`, and the box stays
867
+ // visibly open meanwhile, which is honest about the state.
868
+ function onDocumentPointerDown(e) {
869
+ if (!edit) return;
870
+ if (e.target && edit.el.contains(e.target)) return;
871
+ finishEdit(true, true);
872
+ }
873
+
874
+ // Put the caret in the box, and KEEP putting it there until it sticks.
875
+ //
876
+ // Opening from the context menu is a postMessage with no user activation, so
877
+ // this frame cannot focus itself; the host focuses the preview and
878
+ // preview-shell.js hands that down to this frame (see both). That transition
879
+ // lands AFTER `startEdit` runs, and Chromium resets `activeElement` to BODY
880
+ // as it completes — so the `el.focus()` inside `startEdit` is silently undone
881
+ // and the author's keystrokes go to the app behind the preview. Measured
882
+ // end-to-end in the packaged app: the box opened with the whole frame chain
883
+ // focused, `document.activeElement` sat on `BODY.gp-stage`, and typing landed
884
+ // outside the book. The same `el.focus()` issued a moment later worked, which
885
+ // is what makes this a race rather than a permission problem.
886
+ //
887
+ // So: seat it now, then re-seat across the transition until `activeElement`
888
+ // IS the box. Bounded and idempotent — it stops on the first success, when
889
+ // the edit closes, or after the last attempt, and never fights the author
890
+ // (any element inside the box already counts as seated).
891
+ var SEAT_DELAYS_MS = [0, 16, 50, 150];
892
+
893
+ function seated() {
894
+ return !!edit && (document.activeElement === edit.el || edit.el.contains(document.activeElement));
895
+ }
896
+
897
+ function seatCaret() {
898
+ if (!edit || seated()) return;
899
+ try { edit.el.focus({ preventScroll: true }); } catch (_e) { /* best effort */ }
900
+ placeCaret(edit.el, edit.caret);
901
+ }
902
+
903
+ function seatCaretRepeatedly() {
904
+ var target = edit;
905
+ SEAT_DELAYS_MS.forEach(function (ms) {
906
+ setTimeout(function () {
907
+ if (edit !== target) return;
908
+ seatCaret();
909
+ }, ms);
689
910
  });
690
- return { rects: rects };
911
+ }
912
+
913
+ // Whenever this frame gains focus while an edit is open, the caret belongs in
914
+ // the box — covers orderings where focus arrives later still.
915
+ function onWindowFocus() {
916
+ seatCaret();
917
+ }
918
+
919
+ function startEdit(spec) {
920
+ var el = blocksMatchingRange(spec.chapter, spec.range)[0] || null;
921
+ if (!el) return { ok: false, reason: 'unresolved' };
922
+ if (edit) finishEdit(true, true);
923
+
924
+ var style = el.style;
925
+ edit = {
926
+ el: el,
927
+ chapter: spec.chapter,
928
+ range: spec.range,
929
+ caret: spec.caret,
930
+ html: el.innerHTML,
931
+ hadWhiteSpace: !!style.whiteSpace,
932
+ whiteSpace: style.whiteSpace
933
+ };
934
+ // pre-wrap is load-bearing, not cosmetic: without it the source's newlines
935
+ // collapse and a multi-line block (list, table, fence) is unreadable AND
936
+ // uneditable line-by-line.
937
+ style.whiteSpace = 'pre-wrap';
938
+ el.classList.add('gutterpress-editing');
939
+ el.setAttribute('contenteditable', 'plaintext-only');
940
+ el.textContent = typeof spec.text === 'string' ? spec.text : '';
941
+ el.addEventListener('input', onEditInput);
942
+ document.addEventListener('mousedown', onDocumentPointerDown, true);
943
+ window.addEventListener('focus', onWindowFocus);
944
+ // Pull browsing-context focus into THIS frame first. Without it the caret
945
+ // is nowhere the author can type: the open request came from a click in
946
+ // the host document, so focus is still there.
947
+ try { window.focus(); } catch (_e) { /* focus stays with the host */ }
948
+ // Focus and caret seating are best-effort. A throw here must not escape:
949
+ // the edit state is already installed, so an exception would leave the
950
+ // block stuck in editing mode with the host believing nothing opened.
951
+ try {
952
+ el.focus({ preventScroll: true });
953
+ } catch (_e) {
954
+ try { el.focus(); } catch (_e2) { /* unfocusable host; the box still edits */ }
955
+ }
956
+ placeCaret(el, spec.caret);
957
+ // The swap from rendered HTML to source text changes the block's extent
958
+ // immediately, so pagination is already stale before a single keystroke.
959
+ // This re-parents the box, so seat the caret AFTER it, never before.
960
+ repaginate();
961
+ seatCaretRepeatedly();
962
+ window.dispatchEvent(new CustomEvent('blockEditStateChanged', { detail: { open: true } }));
963
+ return { ok: true };
691
964
  }
692
965
 
693
966
  var api = {
@@ -802,7 +1075,14 @@
802
1075
  // (a point inside a .gp-sheet but outside every author box resolves to
803
1076
  // the sheet's owning .page/.spread). An older SPA simply ignores the
804
1077
  // extra field; a newer SPA feature-detects by the field's presence.
805
- getProtocolVersion: function () { return 7; },
1078
+ // v8: in-flow block editing. ADDED beginBlockEdit()/endBlockEdit() and the
1079
+ // blockEditRequested/blockEditFinished/blockEditStateChanged events;
1080
+ // REMOVED getRectsFor() and
1081
+ // setEditMask(), which existed only to place and de-clutter behind the
1082
+ // floating edit panel this replaces. A v8 lib with a pre-v8 SPA loses the
1083
+ // "Edit this block" action and nothing else; a v8 SPA feature-detects on
1084
+ // the version before offering it.
1085
+ getProtocolVersion: function () { return 8; },
806
1086
 
807
1087
  // Resolve the annotated element/selection at a viewport point (protocol
808
1088
  // v4). Pure read; see buildContextTarget() above for the full contract.
@@ -814,34 +1094,26 @@
814
1094
  return buildContextTarget(contextPointEl(spec.x, spec.y, spec.topmostOnly));
815
1095
  },
816
1096
 
817
- // All fragment rects for one logical block (protocol v6, §5.3), targeted
818
- // by {chapter, range}. Pure read; never mutates the DOM. Plain,
819
- // JSON-cloneable objects only (§3.5) no DOMRect instances.
820
- getRectsFor: function (spec) {
821
- return nativeRectsFor(spec);
1097
+ // Open the in-flow editor on one block (protocol v8). `text` is that
1098
+ // block's markdown source, read SPA-side from the authoritative buffer —
1099
+ // this function never derives source from the DOM. `caret` is the optional
1100
+ // viewport point to seat the caret near. Returns
1101
+ // `{ok: false, reason: 'unresolved'}` when the range no longer matches a
1102
+ // live block, so the SPA can drop the request instead of hanging.
1103
+ beginBlockEdit: function (spec) {
1104
+ spec = spec || {};
1105
+ return startEdit(spec);
822
1106
  },
823
1107
 
824
- // Toggle a masking class on EVERY fragment of a block ({chapter, range}
825
- // match, protocol v6, §5.1/§5.3), plus a scroll lock on the book document
826
- // element. Purely cosmetic and fully reversible nothing here may touch
827
- // anything layout-affecting; see the class definitions below and ADR
828
- // 0009. `masked: false` always removes the lock class too, even if this
829
- // particular range has zero live fragments (e.g. called defensively
830
- // during teardown after a splice) — it is a document-level toggle, not
831
- // scoped per-block, and there is at most one overlay open at a time.
832
- setEditMask: function (spec) {
1108
+ // Close it and hand back the current text (protocol v8). Idempotent:
1109
+ // `{ended: false}` when nothing is open. The SPA calls this to force an end
1110
+ // it initiated (a dialog opening over the workspace); ends the author
1111
+ // initiates from inside the book Escape, Cmd/Ctrl+Enter, blur — arrive
1112
+ // as the `blockEditFinished` event instead, so a keystroke the SPA cannot
1113
+ // see still resolves the edit. Both paths restore the rendered HTML.
1114
+ endBlockEdit: function (spec) {
833
1115
  spec = spec || {};
834
- var els = blocksMatchingRange(spec.chapter, spec.range);
835
- for (var i = 0; i < els.length; i++) {
836
- if (spec.masked) els[i].classList.add('gutterpress-edit-mask');
837
- else els[i].classList.remove('gutterpress-edit-mask');
838
- }
839
- var root = document.documentElement;
840
- if (root && root.classList) {
841
- if (spec.masked) root.classList.add('gutterpress-edit-scroll-lock');
842
- else root.classList.remove('gutterpress-edit-scroll-lock');
843
- }
844
- return { count: els.length };
1116
+ return finishEdit(spec.commit !== false, false);
845
1117
  },
846
1118
 
847
1119
  // Publish any debounced reader movement before a host atomically replaces
@@ -1061,23 +1333,29 @@
1061
1333
  } catch (_e) { /* non-fatal: highlight just renders unstyled */ }
1062
1334
  }
1063
1335
 
1064
- // Block-overlay mask + scroll-lock style (protocol v5, plan §5.1/§5.3).
1065
- // Preview-only, never part of the PDF build path. Purely cosmetic: dims the
1066
- // masked fragment(s) so stale rendered text doesn't show behind/beside the
1067
- // overlay, and disables the book document's own scroll while an overlay is
1068
- // open (the overlay is positioned in host-SPA coordinates from a rect
1069
- // snapshot; an unlocked scroll would silently drift it over unrelated
1070
- // content see BlockOverlayController). Exact visual treatment (dim vs.
1071
- // blank) is a placeholder pending design review (plan §7.6 open item).
1336
+ // In-flow editing style (protocol v8, plan §3.1).
1337
+ // Preview-only, never part of the PDF build path. Marks the block being
1338
+ // edited without moving it: `outline` and `box-shadow` are chosen because
1339
+ // they take NO space in layout a border or padding here would change the
1340
+ // block's extent and repaginate the book on entering edit mode, which is
1341
+ // exactly the divergence this project cares most about. Nothing in this
1342
+ // rule may become layout-affecting.
1343
+ //
1344
+ // No scroll lock and no dimming of the surrounding page: the caret is in the
1345
+ // flow now, so the page can scroll freely and the neighbouring text stays
1346
+ // legible while the author works. Both existed only to prop up the floating
1347
+ // panel this replaced. Exact visual treatment is a design-review item
1348
+ // (plan §6), same as the mask treatment was.
1072
1349
  if (typeof document.createElement === 'function') {
1073
1350
  try {
1074
- var maskStyle = document.createElement('style');
1075
- maskStyle.textContent =
1076
- '.gutterpress-edit-mask{opacity:.2;filter:saturate(.4);pointer-events:none;' +
1077
- 'transition:opacity .12s,filter .12s;}' +
1078
- 'html.gutterpress-edit-scroll-lock{overflow:hidden !important;}';
1079
- (document.head || document.documentElement).appendChild(maskStyle);
1080
- } catch (_e) { /* non-fatal: mask just renders unstyled */ }
1351
+ var editStyle = document.createElement('style');
1352
+ editStyle.textContent =
1353
+ '.gutterpress-editing{outline:2px solid Highlight;outline-offset:2px;' +
1354
+ 'box-shadow:0 0 0 2px color-mix(in srgb, Highlight 25%, transparent);' +
1355
+ 'border-radius:2px;caret-color:currentColor;}' +
1356
+ '.gutterpress-editing:focus{outline-style:solid;}';
1357
+ (document.head || document.documentElement).appendChild(editStyle);
1358
+ } catch (_e) { /* non-fatal: the edit box just renders unmarked */ }
1081
1359
  }
1082
1360
 
1083
1361
  // Click-to-source: emit elementActivated when the user clicks a source-mapped
@@ -1103,6 +1381,10 @@
1103
1381
  // with no replacement menu would be a strict regression. No event is
1104
1382
  // dispatched for 'none' either.
1105
1383
  document.addEventListener('contextmenu', function (e) {
1384
+ // Inside the live in-flow editor the native menu is the useful one
1385
+ // (cut/copy/paste on the source text). Ours offers block actions that
1386
+ // make no sense mid-edit.
1387
+ if (edit && edit.el.contains(e.target)) return;
1106
1388
  var detail = api.getContextTargetAt({ x: e.clientX, y: e.clientY });
1107
1389
  if (detail.kind === 'none') return;
1108
1390
  e.preventDefault();
@@ -1112,6 +1394,47 @@
1112
1394
  window.dispatchEvent(new CustomEvent('contextMenuRequested', { detail: detail }));
1113
1395
  }, true);
1114
1396
 
1397
+ // Double-click to edit (protocol v8, plan §6). The SECOND entry point
1398
+ // alongside the context menu's "Edit this block" — both land on the same
1399
+ // SPA handler, which reads the source slice and calls beginBlockEdit().
1400
+ // Requesting rather than starting is deliberate: only the SPA can read the
1401
+ // authoritative buffer, so the book document never sources its own text.
1402
+ //
1403
+ // Bails while an edit is live so a double-click INSIDE the box keeps its
1404
+ // native meaning (select word). Never preventDefault: on a block that does
1405
+ // not resolve, double-click must keep selecting text as it always has.
1406
+ document.addEventListener('dblclick', function (e) {
1407
+ if (edit) return;
1408
+ var el = e.target && e.target.closest ? e.target.closest('[data-source-range]') : null;
1409
+ if (!el) return;
1410
+ var range = sourceRangeOf(el);
1411
+ var chapter = chapterOf(el);
1412
+ if (!range || !chapter) return;
1413
+ window.dispatchEvent(new CustomEvent('blockEditRequested', {
1414
+ detail: { chapter: chapter, range: range, x: e.clientX, y: e.clientY, via: 'dblclick' }
1415
+ }));
1416
+ }, true);
1417
+
1418
+ // Edit-mode keys. These MUST live inside the book iframe for the same
1419
+ // physical reason the Shift+F10 listener below does: the caret is in a
1420
+ // cross-origin document, so its keystrokes never reach the SPA.
1421
+ // Cmd/Ctrl+Enter commits (Enter alone is a newline — a markdown block can
1422
+ // be multi-line); Escape cancels and restores.
1423
+ document.addEventListener('keydown', function (e) {
1424
+ if (!edit) return;
1425
+ if (e.key === 'Escape') {
1426
+ e.preventDefault();
1427
+ e.stopPropagation();
1428
+ finishEdit(false, true);
1429
+ return;
1430
+ }
1431
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
1432
+ e.preventDefault();
1433
+ e.stopPropagation();
1434
+ finishEdit(true, true);
1435
+ }
1436
+ }, true);
1437
+
1115
1438
  // Shift+F10 / the dedicated ContextMenu key. This listener MUST live
1116
1439
  // inside the book iframe: keyboard events targeted at a focused element
1117
1440
  // in a cross-origin iframe never reach the parent SPA, so an SPA-side
@@ -19,6 +19,11 @@
19
19
  var pendingSwap = null;
20
20
  var pendingSwapTimer = null;
21
21
  var SCROLL_IDLE_MS = 250;
22
+ // True while the book iframe has a live in-flow block editor open
23
+ // (preview-interface.js's blockEditStateChanged, protocol v8). A swap
24
+ // replaces the whole iframe, so swapping mid-edit would destroy the caret
25
+ // and the author's uncommitted typing with it — see holdForEdit below.
26
+ var blockEditOpen = false;
22
27
  if (!active) return;
23
28
 
24
29
  // Transparent bridge relay: forward host-toolbar commands to the active book
@@ -27,26 +32,36 @@
27
32
  try {
28
33
  if (window.parent !== window && e.source === window.parent) {
29
34
  if (active && active.contentWindow) active.contentWindow.postMessage(e.data, '*');
35
+ // Opening the in-flow editor needs KEYBOARD focus in the book, and a
36
+ // postMessage carries no user activation the book could focus itself
37
+ // with. The host focuses this shell frame; this hands it the rest of
38
+ // the way down to the active book iframe, whose own window-focus
39
+ // listener then seats the caret.
40
+ if (e.data && e.data.type === 'gutterpress:cmd' && e.data.cmd === 'beginBlockEdit' && active) {
41
+ try { active.focus(); } catch (_f) {}
42
+ }
30
43
  } else if (active && e.source === active.contentWindow) {
31
44
  var data = e.data;
32
45
  if (data && data.type === 'gutterpress:event' && data.name === 'viewportChanged') {
33
46
  lastViewportChangeAt = Date.now();
34
47
  if (pendingSwap) armPendingSwap();
35
48
  }
36
- if (active === hotReloadFrame && data && data.type === 'gutterpress:event' && data.name === 'ready') return;
37
- if (active === hotReloadFrame && data && data.type === 'gutterpress:event' && data.name === 'renderingComplete') {
38
- var detail = {}, sourceDetail = data.detail || {};
39
- for (var key in sourceDetail) {
40
- if (Object.prototype.hasOwnProperty.call(sourceDetail, key)) detail[key] = sourceDetail[key];
41
- }
42
- detail.hotReload = true;
43
- var startedAt = hotReloadFrame.__gutterpressReloadStartedAt;
44
- detail.hotReloadMs = typeof startedAt === 'number' ? Math.max(0, Date.now() - startedAt) : 0;
45
- detail.revision = hotReloadFrame.__gutterpressRevision;
46
- detail.updateMode = 'full-reload';
47
- data = { type: data.type, name: data.name, detail: detail };
48
- hotReloadFrame = null;
49
+ if (data && data.type === 'gutterpress:event' && data.name === 'blockEditStateChanged') {
50
+ blockEditOpen = !!(data.detail && data.detail.open);
51
+ // Releasing the hold: a swap that arrived mid-edit is still queued,
52
+ // so start it now that the caret is gone.
53
+ if (!blockEditOpen && pendingSwap) armPendingSwap();
49
54
  }
55
+ // A swapped-in frame's own lifecycle events are the shell's business,
56
+ // not the host's: finish() reports the swap (see reportSwapComplete).
57
+ // Relaying them too would either double-report or — when the frame
58
+ // paginates before its `load` event, which is the common case — report
59
+ // nothing at all, because the message arrives while the frame is still
60
+ // `building` and matches no branch here.
61
+ if (
62
+ active === hotReloadFrame && data && data.type === 'gutterpress:event' &&
63
+ (data.name === 'ready' || data.name === 'renderingComplete')
64
+ ) return;
50
65
  if (window.parent !== window) window.parent.postMessage(data, '*');
51
66
  } else if (retiring && e.source === retiring.contentWindow && window.parent !== window) {
52
67
  var retiringData = e.data;
@@ -135,7 +150,33 @@
135
150
  building = null;
136
151
  }
137
152
 
153
+ // Tell the host the swap is done. This is a synchronous fact the shell knows
154
+ // — the replacement is paginated (onReady resolved), promoted, and refreshed
155
+ // — rather than a relayed message whose arrival depends on whether the
156
+ // frame's pagination beat its own `load` event.
157
+ function reportSwapComplete(frame, pageState) {
158
+ if (window.parent === window) return;
159
+ var startedAt = frame.__gutterpressReloadStartedAt;
160
+ window.parent.postMessage({
161
+ type: 'gutterpress:event',
162
+ name: 'renderingComplete',
163
+ detail: {
164
+ totalPages: pageState ? pageState.totalPages : 0,
165
+ hotReload: true,
166
+ hotReloadMs: typeof startedAt === 'number' ? Math.max(0, Date.now() - startedAt) : 0,
167
+ revision: frame.__gutterpressRevision,
168
+ updateMode: 'full-reload'
169
+ }
170
+ }, '*');
171
+ }
172
+
138
173
  function swap(instance, revision) {
174
+ // The editor lived in the frame we are about to retire, so the hold is
175
+ // void whatever the book document last reported. Every update path reaches
176
+ // swap() through armPendingSwap(), which refuses while an edit is open, so
177
+ // this only fires when the frame goes away some other way — but a flag
178
+ // left stuck true would freeze hot reload for the session.
179
+ blockEditOpen = false;
139
180
  discardBuilding();
140
181
  var frame = document.createElement('iframe');
141
182
  frame.style.visibility = 'hidden';
@@ -175,7 +216,8 @@
175
216
  hotReloadFrame = frame;
176
217
  building = null;
177
218
  var api = fwin(frame) && fwin(frame).previewAPI;
178
- if (api && typeof api.refresh === 'function') api.refresh();
219
+ var pageState = api && typeof api.refresh === 'function' ? api.refresh() : null;
220
+ reportSwapComplete(frame, pageState);
179
221
  frame.style.visibility = 'visible';
180
222
  frame.removeAttribute('aria-hidden');
181
223
  appliedInstance = instance;
@@ -229,6 +271,13 @@
229
271
 
230
272
  function armPendingSwap() {
231
273
  if (pendingSwapTimer !== null) clearTimeout(pendingSwapTimer);
274
+ // An open in-flow editor holds the swap indefinitely — there is no timeout
275
+ // to race, because an author may legitimately sit in one block for
276
+ // minutes. The hold is released by blockEditStateChanged{open:false},
277
+ // which preview-interface.js emits on EVERY close path (commit, cancel,
278
+ // blur, SPA-forced), and defensively by swap() below in case the frame
279
+ // carrying the editor goes away first.
280
+ if (blockEditOpen) return;
232
281
  var delay = Math.max(0, SCROLL_IDLE_MS - (Date.now() - lastViewportChangeAt));
233
282
  if (delay === 0) beginPendingSwap();
234
283
  else pendingSwapTimer = setTimeout(beginPendingSwap, delay);
@@ -243,17 +292,23 @@
243
292
  // Paged.js's `.pagedjs_page` DOM to find a chapter's live page range and
244
293
  // graft a freshly-paginated replacement into it. Paged.js has been removed
245
294
  // (native-only-migration-plan.md Phase 6). A native in-place splice was
246
- // also tried and removed (2026-08-08 review): grafting a fresh
247
- // `.gutterpress-chapter` node in and calling `Gutterpress.refresh()` is not
248
- // sound, because `refresh()` -> `relayout()` only re-`measure()`s the
249
- // EXISTING strips it never re-runs `buildStrips()`/`explodeChildren()`.
250
- // So any page context the edit introduces (a new `@page`/`page:`
251
- // assignment inside the chapter) is silently dropped: measured 2 preview
252
- // pages where the same content prints 3 a preview<->PDF divergence, the
253
- // worst failure this project can produce. It also bought nothing: measured
254
- // end-to-end (file write -> change visible, 5 samples, 34pp field guide)
255
- // the plain full reload (`swap`, below) is 509ms avg vs the incremental
256
- // splice's 998ms avg. Every content-update now goes straight to `swap()`.
295
+ // also tried and removed (2026-08-08 review), and the standing reason is
296
+ // PERFORMANCE, not soundness: measured end-to-end (file write -> change
297
+ // visible, 5 samples, 34pp field guide) the plain full reload (`swap`,
298
+ // below) is 509ms avg vs the incremental splice's 998ms avg. Every
299
+ // content-update goes straight to `swap()`.
300
+ //
301
+ // CORRECTED 2026-08-24: the 2026-08-08 review ALSO recorded a soundness
302
+ // objection that `refresh()` -> `relayout()` "only re-measures the
303
+ // EXISTING strips", silently dropping any page context the edit
304
+ // introduces. That is false against the current `fragment.ts`, whose
305
+ // `relayout()` unwraps the strips and re-runs `buildStrips()` from
306
+ // scratch before re-measuring, precisely so a mutation that adds or
307
+ // removes a page-context run is seen. Do not cite the old objection as a
308
+ // reason a DOM mutation cannot be re-paginated — it can, and the inline
309
+ // editing work depends on it (docs/inline-editing-plan.md, ADR 0009
310
+ // decision 4 as revised). The perf comparison above still stands on its
311
+ // own for the full-document reload path.
257
312
 
258
313
  function markActiveReady() {
259
314
  onReady(active, function () {
@@ -7,17 +7,17 @@ import {
7
7
  publishCredentialKey,
8
8
  publishProviderFor,
9
9
  runPublish
10
- } from "./cli-df7gcyns.js";
10
+ } from "./cli-wtyzagaq.js";
11
11
  import {
12
12
  FileTokenStore,
13
13
  log
14
- } from "./cli-mcb484g3.js";
14
+ } from "./cli-qxebvmrq.js";
15
15
  import {
16
16
  UsageError,
17
17
  rejectExtraPositionals,
18
18
  rejectUnknownFlags
19
- } from "./cli-dx6sxj2t.js";
20
- import"./cli-ak1tagkr.js";
19
+ } from "./cli-jhvkcwvf.js";
20
+ import"./cli-eewynen9.js";
21
21
  import"./cli-c41yr7he.js";
22
22
  import {
23
23
  EXIT_CODES
@@ -194,10 +194,13 @@ export interface ResolvedConfig {
194
194
  title: string;
195
195
  authors: string[];
196
196
  /**
197
- * Resolved pagination engine always "native"; Paged.js has been removed.
198
- * See {@link GutterpressManifest.engine}.
197
+ * Resolved pagination engine. Always `"native"` — `manifest.ts` assigns the
198
+ * literal, warning and ignoring an author's `engine: paged`. Typed as the
199
+ * single value it can hold so no consumer can branch on an engine that
200
+ * cannot run. The INPUT union ({@link GutterpressManifest.engine}) keeps
201
+ * both spellings, because old manifests must go on parsing.
199
202
  */
200
- engine: "paged" | "native";
203
+ engine: "native";
201
204
  /** Validated publish-target ids for this book (may be empty). */
202
205
  targets: string[];
203
206
  styles?: string[];
@@ -1,15 +1,15 @@
1
1
  import {
2
2
  executeAndReport
3
- } from "./cli-df7gcyns.js";
3
+ } from "./cli-wtyzagaq.js";
4
4
  import {
5
5
  log
6
- } from "./cli-mcb484g3.js";
6
+ } from "./cli-qxebvmrq.js";
7
7
  import {
8
8
  UsageError,
9
9
  rejectExtraPositionals,
10
10
  rejectUnknownFlags
11
- } from "./cli-dx6sxj2t.js";
12
- import"./cli-ak1tagkr.js";
11
+ } from "./cli-jhvkcwvf.js";
12
+ import"./cli-eewynen9.js";
13
13
  import"./cli-c41yr7he.js";
14
14
  import {
15
15
  EXIT_CODES
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gutterpress",
3
- "version": "0.10.1-beta.1",
3
+ "version": "0.10.1-beta.3",
4
4
  "description": "Markdown-to-PDF converter for professional print layout using a native Chromium print engine and Ghostscript.",
5
5
  "author": "itlackey",
6
6
  "license": "MPL-2.0",