openink 0.1.0 → 0.2.0
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.
- package/CHANGELOG.md +37 -0
- package/README.md +28 -0
- package/docs/ai-assistants.md +29 -0
- package/docs/blocks.md +1 -1
- package/package.json +56 -10
- package/schema/spec.schema.json +1 -1
- package/src/cli.js +1 -1
- package/src/dev.js +16 -1
- package/src/export.js +2 -2
- package/src/render/blocks/layout.js +1 -1
- package/src/runtime/chart.js +2 -1
- package/src/runtime/index.js +2 -0
- package/src/runtime/navigation.js +9 -4
- package/src/runtime/placeholder.js +2 -1
- package/src/runtime/print.js +55 -0
- package/src/styles/openink.css +12 -7
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,43 @@ All notable changes are documented here. The format follows [Keep a Changelog](h
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.2.0] - 2026-10-02
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- A `wireframe` skill for AI coding agents that turns a description into a validated, screenshot-checked prototype in
|
|
12
|
+
any folder. Claude Code: `claude plugin marketplace add mediuswareltd/openink`, then
|
|
13
|
+
`claude plugin install openink@openink`. Cursor, Codex, Gemini CLI, GitHub Copilot and others:
|
|
14
|
+
`npx skills add mediuswareltd/openink`.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- An image, video, map, dropzone or chart placed directly in a `row` is drawn as a 4:3 thumbnail of its height. It
|
|
19
|
+
used to collapse to the width of its label and showed no box (#17).
|
|
20
|
+
- The PDF button (and Ctrl+P) no longer prints image, video and map placeholders and charts as empty boxes with only
|
|
21
|
+
their label. Screens that had not been opened were printed before their sketches were drawn.
|
|
22
|
+
|
|
23
|
+
## [0.1.1] - 2026-10-02
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- Documentation website at <https://mediuswareltd.github.io/openink/> with search, live clickable demos of every example,
|
|
28
|
+
a recipes page of ready-to-paste specs (charts, forms, pricing, tables, modals, mobile) shown beside live previews, and an
|
|
29
|
+
About page.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- `openink dev` uses the next free port (3001, 3002, … up to 10 tries) when the requested one is already in use, instead of failing with `EADDRINUSE`.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- A prototype embedded in an `<iframe>` no longer scrolls the surrounding page down to itself when it loads. Opening a
|
|
38
|
+
prototype without a `#screen` in the URL no longer adds one for the start screen.
|
|
39
|
+
- A tall screen is no longer split across two PDF pages. Each screen is scaled down to fit one A4 landscape page, so the
|
|
40
|
+
PDF has exactly one page per screen. This applies to `openink pdf`, the prototype's PDF button and the browser's
|
|
41
|
+
own print (Ctrl+P).
|
|
42
|
+
- The PDF button now draws the sketched outlines of screens that had never been opened, as `openink pdf` already did.
|
|
43
|
+
|
|
7
44
|
## [0.1.0]
|
|
8
45
|
|
|
9
46
|
First release.
|
package/README.md
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
|
|
10
10
|
Static HTML you can host or open from disk, plus PDF and PNG export. Built on [wired-elements](https://github.com/rough-stuff/wired-elements) and [RoughJS](https://github.com/rough-stuff/rough), so it looks like a sketch and nobody mistakes it for the final design. Keep it plain pencil, or turn on colour for a whole project or just one part.
|
|
11
11
|
|
|
12
|
+
<p align="center">
|
|
13
|
+
<a href="https://mediuswareltd.github.io/openink/"><img src="assets/openink-explainer-poster.jpg" width="720" alt="Open Ink in 35 seconds: watch the explainer video" /></a><br />
|
|
14
|
+
<sub>▶ <a href="https://mediuswareltd.github.io/openink/">Watch Open Ink in 35 seconds</a> (<a href="assets/openink-explainer.mp4">MP4</a>)</sub>
|
|
15
|
+
</p>
|
|
16
|
+
|
|
12
17
|
<p>
|
|
13
18
|
<img src="docs/img/photo-feed.png" width="32%" alt="Photo-sharing app: feed with stories, carousel and action icons" />
|
|
14
19
|
<img src="docs/img/photo-mobile.png" width="32%" alt="The same feed inside a phone frame with a tab bar" />
|
|
@@ -77,6 +82,27 @@ openink dev
|
|
|
77
82
|
|
|
78
83
|
Requires Node 20+. PDF and PNG export need Chrome, Chromium or Edge installed (set `CHROME_PATH` if it isn't found).
|
|
79
84
|
|
|
85
|
+
### With an AI coding agent
|
|
86
|
+
|
|
87
|
+
Give your agent the Open Ink skill once, then just describe what you want ("wireframe a booking app for a hair salon").
|
|
88
|
+
|
|
89
|
+
**Claude Code**: install the plugin:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
claude plugin marketplace add mediuswareltd/openink
|
|
93
|
+
claude plugin install openink@openink
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
To get updates automatically, turn on auto-update for the **openink** marketplace in `/plugin` → **Marketplaces**.
|
|
97
|
+
|
|
98
|
+
**Cursor, Codex, Gemini CLI, GitHub Copilot, Windsurf and other agents**: add the skill with the [skills](https://skills.sh) CLI, which asks which of your agents to install it for:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npx skills add mediuswareltd/openink
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The agent writes and validates the spec, checks screenshots of every screen and tells you where the prototype is. See [docs/ai-assistants.md](docs/ai-assistants.md).
|
|
105
|
+
|
|
80
106
|
## What you can draw
|
|
81
107
|
|
|
82
108
|
48 blocks in 10 groups; see the [block reference](docs/blocks.md) and the [gallery example](examples/gallery), which shows every one.
|
|
@@ -139,6 +165,8 @@ npx openink dev examples/photo-sharing
|
|
|
139
165
|
|
|
140
166
|
## Documentation
|
|
141
167
|
|
|
168
|
+
**[mediuswareltd.github.io/openink](https://mediuswareltd.github.io/openink/)**: the full docs, with search and live demos of every example.
|
|
169
|
+
|
|
142
170
|
- [Getting started](docs/getting-started.md)
|
|
143
171
|
- [Spec reference](docs/spec.md): screens, colour, modals, navigation, languages, icons
|
|
144
172
|
- [Block reference](docs/blocks.md)
|
package/docs/ai-assistants.md
CHANGED
|
@@ -6,6 +6,35 @@ Open Ink is designed so that an assistant (Claude Code, Cursor, Copilot, ChatGPT
|
|
|
6
6
|
|
|
7
7
|
The assistant writes `spec.yaml`, the tool checks it, and you get a consistent hand-drawn result. Because the spec is small and validated, it is cheap to iterate: "add a second tab to the listing page" is a five-line diff.
|
|
8
8
|
|
|
9
|
+
## Install the Open Ink skill
|
|
10
|
+
|
|
11
|
+
Open Ink comes with a `wireframe` skill that teaches your agent the whole loop. Once it is installed, the agent uses it whenever you ask for a wireframe, mockup or clickable prototype, in any folder, so you do not need to mention Open Ink or run `init` first.
|
|
12
|
+
|
|
13
|
+
The skill follows the same steps as the `AGENTS.md` below: it reads the block reference, edits only `spec.yaml`, validates until the spec is clean, checks a PNG of every screen and tells you where the result is. In a folder without a spec, it creates the project in a new subfolder so it does not overwrite your files.
|
|
14
|
+
|
|
15
|
+
### Claude Code
|
|
16
|
+
|
|
17
|
+
Install the plugin once:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
claude plugin marketplace add mediuswareltd/openink
|
|
21
|
+
claude plugin install openink@openink
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Inside a session the same is `/plugin marketplace add mediuswareltd/openink` and `/plugin install openink@openink`. You can also call the skill directly with `/openink:wireframe`.
|
|
25
|
+
|
|
26
|
+
For updates, turn on auto-update for the **openink** marketplace in `/plugin` → **Marketplaces**, or run `/plugin marketplace update openink`.
|
|
27
|
+
|
|
28
|
+
### Cursor, Codex, Gemini CLI, GitHub Copilot and other agents
|
|
29
|
+
|
|
30
|
+
Add the skill with the [skills](https://skills.sh) CLI:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx skills add mediuswareltd/openink
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
It asks which of your agents to install it for, and whether to install it for this project or for your user. To skip the questions, name the agents and add `-y`, for example `npx skills add mediuswareltd/openink -a cursor -a codex -y`, and add `-g` to install it for every project. Run `npx skills update` to get the latest version.
|
|
37
|
+
|
|
9
38
|
## How it works
|
|
10
39
|
|
|
11
40
|
`openink init` puts an `AGENTS.md` in your project. Most assistants read it automatically; it tells them to:
|
package/docs/blocks.md
CHANGED
|
@@ -43,7 +43,7 @@ Vertical stack of blocks.
|
|
|
43
43
|
|
|
44
44
|
### `row` · children
|
|
45
45
|
|
|
46
|
-
Horizontal row that wraps on small screens.
|
|
46
|
+
Horizontal row that wraps on small screens. Images, videos, maps and charts in a row are 4:3 thumbnails of their height.
|
|
47
47
|
|
|
48
48
|
| Prop | Type | Description |
|
|
49
49
|
|---|---|---|
|
package/package.json
CHANGED
|
@@ -1,24 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openink",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Describe screens in YAML, get a clickable hand-drawn wireframe prototype (static HTML, PDF, PNG). Built on wired-elements and RoughJS.",
|
|
5
|
-
"keywords": [
|
|
5
|
+
"keywords": [
|
|
6
|
+
"wireframe",
|
|
7
|
+
"prototype",
|
|
8
|
+
"sketch",
|
|
9
|
+
"mockup",
|
|
10
|
+
"hand-drawn",
|
|
11
|
+
"wired-elements",
|
|
12
|
+
"roughjs",
|
|
13
|
+
"ux",
|
|
14
|
+
"static-site-generator"
|
|
15
|
+
],
|
|
6
16
|
"license": "MIT",
|
|
7
17
|
"author": "Mediusware",
|
|
8
|
-
"repository": {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/mediuswareltd/openink.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://mediuswareltd.github.io/openink/",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/mediuswareltd/openink/issues"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
12
29
|
"type": "module",
|
|
13
|
-
"bin": {
|
|
30
|
+
"bin": {
|
|
31
|
+
"openink": "bin/openink.js"
|
|
32
|
+
},
|
|
14
33
|
"main": "./src/index.js",
|
|
15
34
|
"exports": {
|
|
16
35
|
".": "./src/index.js",
|
|
17
36
|
"./schema.json": "./schema/spec.schema.json",
|
|
18
37
|
"./package.json": "./package.json"
|
|
19
38
|
},
|
|
20
|
-
"files": [
|
|
21
|
-
|
|
39
|
+
"files": [
|
|
40
|
+
"bin",
|
|
41
|
+
"src",
|
|
42
|
+
"templates",
|
|
43
|
+
"schema",
|
|
44
|
+
"docs",
|
|
45
|
+
"!docs/img",
|
|
46
|
+
"!docs/.vitepress",
|
|
47
|
+
"!docs/public",
|
|
48
|
+
"!docs/index.md",
|
|
49
|
+
"!docs/examples.md",
|
|
50
|
+
"!docs/about.md",
|
|
51
|
+
"!docs/snippets",
|
|
52
|
+
"!docs/recipes.md",
|
|
53
|
+
"LICENSE",
|
|
54
|
+
"README.md",
|
|
55
|
+
"CHANGELOG.md"
|
|
56
|
+
],
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=20"
|
|
59
|
+
},
|
|
22
60
|
"scripts": {
|
|
23
61
|
"generate": "node scripts/generate.js",
|
|
24
62
|
"test": "node --test \"test/*.test.js\"",
|
|
@@ -26,7 +64,12 @@
|
|
|
26
64
|
"prepublishOnly": "npm test",
|
|
27
65
|
"lint:commit": "node scripts/commit-lint.js",
|
|
28
66
|
"screenshots": "node scripts/screenshots.js",
|
|
29
|
-
"dev": "node bin/openink.js dev examples/rental-portal"
|
|
67
|
+
"dev": "node bin/openink.js dev examples/rental-portal",
|
|
68
|
+
"docs:demos": "node scripts/build-demos.js",
|
|
69
|
+
"docs:dev": "npm run docs:demos && vitepress dev docs",
|
|
70
|
+
"docs:build": "npm run docs:demos && vitepress build docs",
|
|
71
|
+
"docs:preview": "vitepress preview docs",
|
|
72
|
+
"docs:deploy": "npm run docs:build && node scripts/deploy-docs.js"
|
|
30
73
|
},
|
|
31
74
|
"dependencies": {
|
|
32
75
|
"esbuild": "^0.28.2",
|
|
@@ -34,5 +77,8 @@
|
|
|
34
77
|
"roughjs": "4.3.1",
|
|
35
78
|
"wired-elements": "3.0.0-rc.6",
|
|
36
79
|
"yaml": "^2.5.0"
|
|
80
|
+
},
|
|
81
|
+
"devDependencies": {
|
|
82
|
+
"vitepress": "^1.6.4"
|
|
37
83
|
}
|
|
38
84
|
}
|
package/schema/spec.schema.json
CHANGED
|
@@ -364,7 +364,7 @@
|
|
|
364
364
|
},
|
|
365
365
|
{
|
|
366
366
|
"type": "object",
|
|
367
|
-
"description": "Horizontal row that wraps on small screens.",
|
|
367
|
+
"description": "Horizontal row that wraps on small screens. Images, videos, maps and charts in a row are 4:3 thumbnails of their height.",
|
|
368
368
|
"required": [
|
|
369
369
|
"type"
|
|
370
370
|
],
|
package/src/cli.js
CHANGED
|
@@ -23,7 +23,7 @@ Commands:
|
|
|
23
23
|
|
|
24
24
|
Options:
|
|
25
25
|
--out <dir> Output directory, relative to the project (default: dist)
|
|
26
|
-
--port <n> Dev server port (default: 3000)
|
|
26
|
+
--port <n> Dev server port (default: 3000; the next free port if taken)
|
|
27
27
|
--theme <name> Try a colour theme without editing the spec: sketch, color, pastel, blueprint, dark
|
|
28
28
|
-v, --version Print the version
|
|
29
29
|
-h, --help Show this help
|
package/src/dev.js
CHANGED
|
@@ -9,6 +9,8 @@ const MIME = {
|
|
|
9
9
|
".webp": "image/webp", ".ico": "image/x-icon", ".woff2": "font/woff2", ".pdf": "application/pdf",
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
const MAX_PORT_TRIES = 10;
|
|
13
|
+
|
|
12
14
|
/**
|
|
13
15
|
* Serve the project with live reload: rebuilds when files change and the browser refreshes itself.
|
|
14
16
|
* @param {{ dir?: string, out?: string, port?: number, theme?: string, log?: (msg: string) => void, onIssues?: (e: SpecError) => void }} [opts]
|
|
@@ -38,7 +40,20 @@ export async function dev({ dir = ".", out = ".openink-dev", port = 3000, theme,
|
|
|
38
40
|
res.writeHead(200, { "Content-Type": MIME[path.extname(file)] || "application/octet-stream", "Cache-Control": "no-store" });
|
|
39
41
|
fs.createReadStream(file).pipe(res);
|
|
40
42
|
});
|
|
41
|
-
|
|
43
|
+
// If the port is taken, try the next ones (3000 → 3001 → …) before giving up.
|
|
44
|
+
const wanted = port;
|
|
45
|
+
for (let attempt = 0; ; attempt++, port++) {
|
|
46
|
+
try {
|
|
47
|
+
await new Promise((resolve, reject) => server.once("error", reject).listen(port, () => { server.off("error", reject); resolve(); }));
|
|
48
|
+
break;
|
|
49
|
+
} catch (e) {
|
|
50
|
+
if (e.code !== "EADDRINUSE" || attempt >= MAX_PORT_TRIES - 1) {
|
|
51
|
+
fs.rmSync(outDir, { recursive: true, force: true });
|
|
52
|
+
throw e.code === "EADDRINUSE" ? new Error(`Ports ${wanted}–${port} are all in use. Pick another with --port.`) : e;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (port !== wanted) log(`Port ${wanted} is in use, using ${port} instead.`);
|
|
42
57
|
|
|
43
58
|
let timer;
|
|
44
59
|
const watcher = fs.watch(projectDir, { recursive: true }, (_, name) => {
|
package/src/export.js
CHANGED
|
@@ -55,9 +55,9 @@ export async function exportFiles({ dir = ".", out = "dist", png = false, theme
|
|
|
55
55
|
return { files };
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
//
|
|
58
|
+
// Lay the page out for print and shrink any screen too tall for one page, as the PDF button does.
|
|
59
59
|
await page.emulateMediaType("print");
|
|
60
|
-
await page.evaluate(() =>
|
|
60
|
+
await page.evaluate(() => window.openink.preparePrint());
|
|
61
61
|
await wait(500);
|
|
62
62
|
const file = path.join(outDir, `${slug(spec.name)}.pdf`);
|
|
63
63
|
await page.pdf({ path: file, format: "A4", landscape: true, printBackground: true, preferCSSPageSize: true });
|
|
@@ -13,7 +13,7 @@ export default [
|
|
|
13
13
|
{
|
|
14
14
|
name: "row",
|
|
15
15
|
group: "Layout",
|
|
16
|
-
summary: "Horizontal row that wraps on small screens.",
|
|
16
|
+
summary: "Horizontal row that wraps on small screens. Images, videos, maps and charts in a row are 4:3 thumbnails of their height.",
|
|
17
17
|
children: true,
|
|
18
18
|
props: {
|
|
19
19
|
between: { type: "boolean", doc: "Push the first and last child to opposite ends." },
|
package/src/runtime/chart.js
CHANGED
|
@@ -12,7 +12,7 @@ class Chart extends HTMLElement {
|
|
|
12
12
|
const kind = this.getAttribute("kind") || "line";
|
|
13
13
|
const values = (this.getAttribute("values") || "").split(",").map((s) => s.trim()).filter(Boolean).map(Number).filter(Number.isFinite);
|
|
14
14
|
const data = values.length ? values : SAMPLE[kind] || SAMPLE.line;
|
|
15
|
-
this.style.cssText += `display:block;position:relative;height:${h}px`;
|
|
15
|
+
this.style.cssText += `display:block;position:relative;height:${h}px;--oi-w:${Math.round((h * 4) / 3)}px`;
|
|
16
16
|
|
|
17
17
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
18
18
|
svg.style.cssText = "position:absolute;inset:0;width:100%;height:100%";
|
|
@@ -70,6 +70,7 @@ class Chart extends HTMLElement {
|
|
|
70
70
|
}
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
+
this.draw = draw; // print draws every screen at once, before a ResizeObserver would fire
|
|
73
74
|
draw();
|
|
74
75
|
new ResizeObserver(draw).observe(this);
|
|
75
76
|
const label = this.getAttribute("label");
|
package/src/runtime/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import "./chart.js";
|
|
|
5
5
|
import { $$, redraw } from "./dom.js";
|
|
6
6
|
import { go, toast, initNavigation } from "./navigation.js";
|
|
7
7
|
import { setLang, initI18n } from "./i18n.js";
|
|
8
|
+
import { initPrint } from "./print.js";
|
|
8
9
|
|
|
9
10
|
function select(buttons, active) {
|
|
10
11
|
buttons.forEach((b) => {
|
|
@@ -61,4 +62,5 @@ document.addEventListener("toggle", (e) => e.target.tagName === "DETAILS" && red
|
|
|
61
62
|
window.addEventListener("DOMContentLoaded", () => {
|
|
62
63
|
initI18n();
|
|
63
64
|
initNavigation();
|
|
65
|
+
initPrint();
|
|
64
66
|
});
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { $$, config, redraw } from "./dom.js";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Show one screen (and the header nav set it asks for).
|
|
5
|
+
* `remember: false` leaves the URL alone: a "#screen" written before the page has loaded makes the
|
|
6
|
+
* browser scroll to it, and inside an iframe that scrolls the embedding page too.
|
|
7
|
+
*/
|
|
8
|
+
export function go(id, { remember = true } = {}) {
|
|
5
9
|
const el = document.getElementById(id);
|
|
6
10
|
if (!el || !el.classList.contains("screen")) return;
|
|
7
11
|
$$(".screen").forEach((s) => s.classList.remove("on"));
|
|
8
12
|
el.classList.add("on");
|
|
9
13
|
$$("header nav").forEach((n) => (n.hidden = n.dataset.nav !== el.dataset.nav));
|
|
10
14
|
document.title = el.dataset.title + " · " + document.title.split(" · ").pop();
|
|
11
|
-
history.replaceState(null, "", "#" + id);
|
|
15
|
+
if (remember) history.replaceState(null, "", "#" + id);
|
|
12
16
|
window.scrollTo({ top: 0 });
|
|
13
17
|
redraw();
|
|
14
18
|
}
|
|
@@ -23,5 +27,6 @@ export function toast(message) {
|
|
|
23
27
|
|
|
24
28
|
export function initNavigation() {
|
|
25
29
|
window.addEventListener("hashchange", () => go(location.hash.slice(1)));
|
|
26
|
-
|
|
30
|
+
// The start screen: the URL already says which one, or it is the first screen.
|
|
31
|
+
go(location.hash.slice(1) || config.first, { remember: false });
|
|
27
32
|
}
|
|
@@ -11,7 +11,7 @@ class Placeholder extends HTMLElement {
|
|
|
11
11
|
const h = +this.getAttribute("h") || 120;
|
|
12
12
|
// A round placeholder (avatar) is square and must have a width of its own to sit in a flex row.
|
|
13
13
|
const width = this.hasAttribute("round") ? `width:${h}px;flex:none;` : "";
|
|
14
|
-
this.style.cssText += `display:block;position:relative;height:${h}px;${width}`;
|
|
14
|
+
this.style.cssText += `display:block;position:relative;height:${h}px;--oi-w:${Math.round((h * 4) / 3)}px;${width}`;
|
|
15
15
|
|
|
16
16
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
17
17
|
svg.style.cssText = "position:absolute;inset:0;width:100%;height:100%";
|
|
@@ -72,6 +72,7 @@ class Placeholder extends HTMLElement {
|
|
|
72
72
|
}
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
+
this.draw = draw; // print draws every screen at once, before a ResizeObserver would fire
|
|
75
76
|
draw();
|
|
76
77
|
new ResizeObserver(draw).observe(this);
|
|
77
78
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { $$, redraw } from "./dom.js";
|
|
2
|
+
|
|
3
|
+
// Printable height of the @page in openink.css (A4 landscape less 10mm margins), in CSS pixels.
|
|
4
|
+
const PAGE_HEIGHT = (190 * 96) / 25.4;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Lay the page out for print (every screen, one per page), shrink each screen that is taller than
|
|
8
|
+
* one printed page so it is not split across two, and draw the wired-elements that have never been
|
|
9
|
+
* visible. Runs on `beforeprint` (the PDF button, Ctrl+P) and from `openink export`.
|
|
10
|
+
*/
|
|
11
|
+
export function preparePrint() {
|
|
12
|
+
document.documentElement.classList.add("oi-print");
|
|
13
|
+
const screens = $$(".screen");
|
|
14
|
+
screens.forEach(unzoom);
|
|
15
|
+
|
|
16
|
+
// The header shares the first screen's page and the footer the last one's, so their height comes
|
|
17
|
+
// off that page's budget.
|
|
18
|
+
const docBottom = document.documentElement.scrollHeight;
|
|
19
|
+
const zooms = screens.map((s, i) => {
|
|
20
|
+
const r = s.getBoundingClientRect();
|
|
21
|
+
const top = r.top + scrollY;
|
|
22
|
+
const fixed = (i === 0 ? top : 0) + (i === screens.length - 1 ? docBottom - (top + r.height) : 0);
|
|
23
|
+
const room = (PAGE_HEIGHT - fixed) * 0.97; // a little slack for rounding and collapsed margins
|
|
24
|
+
return { zoom: r.height > room ? Math.max(room / r.height, 0.3) : 1, width: r.width };
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Draw everything now: the browser prints straight after this returns, before a ResizeObserver
|
|
28
|
+
// would draw the placeholders and charts on screens that were hidden.
|
|
29
|
+
// Draw before zooming: wired-elements size their sketch from the zoomed box, and the zoom then
|
|
30
|
+
// shrinks the sketch a second time. Keep the screen's unzoomed width so its layout (and so the
|
|
31
|
+
// sketches) only scales, and record the zoomed box as the size last drawn, so a resize
|
|
32
|
+
// (wired-card watches its own) does not redraw at that size.
|
|
33
|
+
$$("*").forEach((el) => (el.wiredRender ? el.wiredRender(true) : el.draw?.()));
|
|
34
|
+
screens.forEach((s, i) => {
|
|
35
|
+
const { zoom, width } = zooms[i];
|
|
36
|
+
if (zoom === 1) return;
|
|
37
|
+
Object.assign(s.style, { zoom: String(zoom), width: `${width}px`, marginInline: "auto" });
|
|
38
|
+
$$("*", s).forEach((el) => el.canvasSize && el.lastSize && (el.lastSize = el.canvasSize()));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Undo preparePrint and redraw the screen that is showing. */
|
|
43
|
+
export function endPrint() {
|
|
44
|
+
document.documentElement.classList.remove("oi-print");
|
|
45
|
+
$$(".screen").forEach(unzoom);
|
|
46
|
+
redraw();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const unzoom = (s) => Object.assign(s.style, { zoom: "", width: "", marginInline: "" });
|
|
50
|
+
|
|
51
|
+
export function initPrint() {
|
|
52
|
+
window.addEventListener("beforeprint", preparePrint);
|
|
53
|
+
window.addEventListener("afterprint", endPrint);
|
|
54
|
+
window.openink = { ...window.openink, preparePrint, endPrint };
|
|
55
|
+
}
|
package/src/styles/openink.css
CHANGED
|
@@ -101,8 +101,10 @@ p { margin: 0; }
|
|
|
101
101
|
.row { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
|
|
102
102
|
.row.between { justify-content: space-between; }
|
|
103
103
|
.row.center { justify-content: center; }
|
|
104
|
+
/* placeholders and charts have no content width, so in a row they are a 4:3 thumbnail (--oi-w is set from their height) */
|
|
105
|
+
.row > oi-placeholder:not([round]), .row > oi-chart { flex: none; width: var(--oi-w); max-width: 100%; }
|
|
104
106
|
.grid { display: grid; grid-template-columns: repeat(var(--cols), 1fr); gap: var(--gap); align-items: start; }
|
|
105
|
-
@media (max-width: 720px) { .grid { grid-template-columns: 1fr; } }
|
|
107
|
+
@media (max-width: 720px) { html:not(.oi-print) .grid { grid-template-columns: 1fr; } }
|
|
106
108
|
.card { display: block; padding: 1rem; background: var(--card-bg); }
|
|
107
109
|
.card.dash { background: transparent; }
|
|
108
110
|
.center-items { justify-items: center; text-align: center; }
|
|
@@ -238,13 +240,16 @@ oi-placeholder[upload] .ph-label { inset: auto 0 22px 0; }
|
|
|
238
240
|
.toast { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); background: var(--ink); color: var(--paper); padding: .5rem 1rem; border-radius: 6px; opacity: 0; transition: opacity .2s; pointer-events: none; z-index: 100; }
|
|
239
241
|
.toast.show { opacity: 1; }
|
|
240
242
|
|
|
241
|
-
/* print / PDF: every screen on its own page
|
|
243
|
+
/* print / PDF: every screen on its own page. The runtime adds .oi-print before printing (and removes
|
|
244
|
+
it after) so it can measure the print layout and shrink screens too tall for one page; the layout
|
|
245
|
+
width is the @page's printable width. */
|
|
242
246
|
@page { size: A4 landscape; margin: 10mm; }
|
|
243
247
|
@media print {
|
|
244
248
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
245
|
-
.oi-bar nav, .lang, .print-btn, .toast, .nextbar, .modal { display: none !important; }
|
|
246
|
-
.screen, .screen.on { display: block !important; break-after: page; }
|
|
247
|
-
.screen:last-of-type { break-after: auto; }
|
|
248
|
-
.screen-title { display: block; font-family: "Caveat", cursive; font-size: 1.3rem; color: var(--muted); border-bottom: 2px dashed var(--line); }
|
|
249
|
-
.tab-panel { display: block; }
|
|
250
249
|
}
|
|
250
|
+
html.oi-print { width: 277mm; }
|
|
251
|
+
.oi-print .oi-bar nav, .oi-print .lang, .oi-print .print-btn, .oi-print .toast, .oi-print .nextbar, .oi-print .modal { display: none !important; }
|
|
252
|
+
.oi-print .screen, .oi-print .screen.on { display: block !important; break-after: page; }
|
|
253
|
+
.oi-print .screen:last-of-type { break-after: auto; }
|
|
254
|
+
.oi-print .screen-title { display: block; font-family: "Caveat", cursive; font-size: 1.3rem; color: var(--muted); border-bottom: 2px dashed var(--line); }
|
|
255
|
+
.oi-print .tab-panel { display: block; }
|