Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion components/presently/Presently.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export {Slide, SlideBuilder, SlideContext, SlideElements} from './Presently/Slide.js';
export {applyCodeFocus} from './Presently/CodeFocus.js';
export {SlideRendering} from './Presently/SlideRendering.js';
export {SlideRendering, prepareSlideBackgrounds} from './Presently/SlideRendering.js';
export {runAllSlideScripts, runScript} from './Presently/Scripts.js';
17 changes: 14 additions & 3 deletions components/presently/Presently/SlideRendering.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,19 @@ import {runScript} from './Scripts.js';

const SLIDE_CHANGE_EVENT = 'presently:slide:change';

async function prepareSlides() {
await Syntax.highlight();
// Wait for background images to decode before capturing a transition or export.
// A missing or invalid image must not prevent the slides from becoming ready.
export async function prepareSlideBackgrounds(root = document) {
const urls = new Set(Array.from(root.querySelectorAll('.slide-surface[data-background]'), surface => surface.dataset.background));
await Promise.all(Array.from(urls, async url => {
const image = new Image();
image.src = url;
await image.decode().catch(() => {});
}));
}

async function prepareSlides(view) {
await Promise.all([Syntax.highlight(), prepareSlideBackgrounds(view)]);
await applyCodeFocus();
}

Expand Down Expand Up @@ -42,7 +53,7 @@ export class SlideRendering {
if (slide) this.#slides.push(slide);
});

await prepareSlides();
await prepareSlides(this.#view);
return !this.#disposed;
}

Expand Down
81 changes: 80 additions & 1 deletion components/presently/test/SlideRendering.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';

import Syntax from '@socketry/syntax';
import {SlideRendering} from '../Presently/SlideRendering.js';
import {SlideRendering, prepareSlideBackgrounds} from '../Presently/SlideRendering.js';

globalThis.window = globalThis;

Expand All @@ -29,14 +29,93 @@ class View extends EventTarget {
super();
this.id = id;
this.slides = slides;
this.backgrounds = [];
}

querySelectorAll(selector) {
if (selector === '.slide-surface[data-background]') return this.backgrounds;
assert.equal(selector, '.slide');
return this.slides;
}
}

test('background preparation waits for decoding, deduplicates URLs, and tolerates missing images', async () => {
const images = [];
const originalImage = globalThis.Image;
globalThis.Image = class {
constructor() {
images.push(this);
}
decode() {
return new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
}
};

try {
const view = new View('backgrounds', []);
view.backgrounds = ['image.jpg', 'missing.jpg', 'image.jpg'].map(background => ({dataset: {background}}));
let ready = false;
const prepared = prepareSlideBackgrounds(view).then(() => ready = true);
assert.deepEqual(images.map(image => image.src), ['image.jpg', 'missing.jpg']);
images[0].resolve();
await Promise.resolve();
assert.equal(ready, false);
images[1].reject(new Error('Missing image'));
await prepared;
assert.equal(ready, true);
} finally {
globalThis.Image = originalImage;
}
});

test('a transition waits for its background before capturing the incoming slide', async () => {
const originalHighlight = Syntax.highlight;
const originalImage = globalThis.Image;
Syntax.highlight = async () => {};
let decoded;
globalThis.Image = class {
decode() {
return new Promise(resolve => decoded = resolve);
}
};
let transition;
globalThis.document = {
documentElement: {dataset: {}},
querySelectorAll: () => [],
startViewTransition: callback => transition = pendingTransition(callback),
};

try {
const view = new View('background', []);
const rendering = new SlideRendering(view, {transition: 'fade'});
let changes = 0;
view.addEventListener('presently:slide:change', () => changes += 1);
const rendered = rendering.render(() => {
view.backgrounds = [{dataset: {background: 'image.jpg'}}];
});
transition.update();
let captured = false;
transition.updateCallbackDone.then(() => captured = true);
await Promise.resolve();
assert.equal(captured, false);
assert.equal(changes, 0);
decoded();
await transition.updateCallbackDone;
assert.equal(captured, true);
transition.finish();
assert.equal(await rendered, true);
assert.equal(changes, 1);
rendering.dispose();
} finally {
Syntax.highlight = originalHighlight;
globalThis.Image = originalImage;
delete globalThis.document;
}
});

function animatedSlide() {
const slide = new SlideElement(`
slide.anime(({utils}) => utils.set(slide.element.camera, {zoom: 3}));
Expand Down
30 changes: 30 additions & 0 deletions context/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,36 @@ Relative images embedded in Markdown resolve from the Markdown file's directory,

Presently loads each discovered stylesheet once in deterministic presentation order. Directory styles are loaded from parent to child before the matching slide sidecar, so more specific styles naturally appear later in the cascade. The same stylesheets are used by the display, presenter, recorder, playback, and export interfaces.

### Background Images

Add a background image behind any slide template using frontmatter:

``` markdown
---
template: title
background: images/mountains.jpg
background-size: cover
---

# Welcome
```

The background fills the available slide surface, including space outside the 16:9 content area. Content keeps its 16:9 layout. Images are centred and do not repeat.

Relative image paths resolve from the slide file's directory, just like Markdown images. Root-relative paths and external URLs are also supported.

`background-size` accepts:

| Value | Behaviour |
|---|---|
| `cover` (default) | Preserves proportions and fills the surface, cropping the image when necessary. |
| `contain` | Preserves proportions and shows the whole image, potentially leaving uncovered space. |
| `auto` | Uses the image's natural size. |

Missing or unrecognised sizes use `cover`. An explicit background image replaces the template's background image or gradient; uncovered space shows the underlying slide background colour. Slides without a background image retain their template's appearance.

Backgrounds appear in the display, presenter previews, recorder, playback, and PDF export. A preview or export with a different aspect ratio can crop the image differently from the audience display.

### Running the Presentation

Start the server from your presentation directory:
Expand Down
30 changes: 30 additions & 0 deletions guides/getting-started/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,36 @@ Relative images embedded in Markdown resolve from the Markdown file's directory,

Presently loads each discovered stylesheet once in deterministic presentation order. Directory styles are loaded from parent to child before the matching slide sidecar, so more specific styles naturally appear later in the cascade. The same stylesheets are used by the display, presenter, recorder, playback, and export interfaces.

### Background Images

Add a background image behind any slide template using frontmatter:

``` markdown
---
template: title
background: images/mountains.jpg
background-size: cover
---

# Welcome
```

The background fills the available slide surface, including space outside the 16:9 content area. Content keeps its 16:9 layout. Images are centred and do not repeat.

Relative image paths resolve from the slide file's directory, just like Markdown images. Root-relative paths and external URLs are also supported.

`background-size` accepts:

| Value | Behaviour |
|---|---|
| `cover` (default) | Preserves proportions and fills the surface, cropping the image when necessary. |
| `contain` | Preserves proportions and shows the whole image, potentially leaving uncovered space. |
| `auto` | Uses the image's natural size. |

Missing or unrecognised sizes use `cover`. An explicit background image replaces the template's background image or gradient; uncovered space shows the underlying slide background colour. Slides without a background image retain their template's appearance.

Backgrounds appear in the display, presenter previews, recorder, playback, and PDF export. A preview or export with a different aspect ratio can crop the image differently from the audience display.

### Running the Presentation

Start the server from your presentation directory:
Expand Down
43 changes: 33 additions & 10 deletions lib/presently/slide.rb
Original file line number Diff line number Diff line change
Expand Up @@ -272,24 +272,31 @@ def expand_includes!(document, base_dir, root, depth: 0)
# @parameter source_path [String] The Markdown source path.
# @parameter root [String] The presentation asset root.
def rewrite_image_urls!(document, source_path, root)
document.walk do |node|
node.url = resolve_image_url(node.url, source_path, root) if node.type == :image
end
end

# Resolve an image URL relative to its source file within the presentation.
# @parameter url [String] The image URL.
# @parameter source_path [String] The source file path.
# @parameter root [String] The presentation asset root.
# @returns [String] The resolved URL, preserving absolute URLs.
def resolve_image_url(url, source_path, root)
reference = Protocol::URL[url]
return url unless reference.is_a?(Protocol::URL::Relative)
return url if reference.path.empty? || reference.path.absolute?

directory = File.dirname(File.expand_path(source_path))
root = File.expand_path(root)
return unless directory == root || directory.start_with?(root + File::SEPARATOR)
return url unless directory == root || directory.start_with?(root + File::SEPARATOR)

relative_directory = directory.delete_prefix(root).delete_prefix(File::SEPARATOR)
base_path = Stylesheet::PREFIX
base_path += Stylesheet.encode_path(relative_directory) + "/" unless relative_directory.empty?
base_url = Protocol::URL::Relative.new(base_path)

document.walk do |node|
next unless node.type == :image

url = Protocol::URL[node.url]
next unless url.is_a?(Protocol::URL::Relative)
next if url.path.empty? || url.path.absolute?

node.url = (base_url + url).to_s
end
(base_url + reference).to_s
end

end
Expand Down Expand Up @@ -348,6 +355,22 @@ def template
@front_matter&.fetch("template", "default") || "default"
end

# The background image URL, resolved relative to this slide's source file.
# @returns [String | Nil] The URL, or `nil` when no image is specified.
def background
value = @front_matter&.fetch("background", nil)
return unless value.is_a?(String) && !value.strip.empty?

Parser.resolve_image_url(value, source_path, @presentation.root)
end

# How the background image is sized within the full slide surface.
# @returns [String] `"cover"`, `"contain"`, or `"auto"`, defaulting to `"cover"`.
def background_size
value = @front_matter&.fetch("background-size", nil)
["cover", "contain", "auto"].include?(value) ? value : "cover"
end

# The expected duration of this slide in seconds.
# Negative, invalid, or non-finite values are treated as zero.
# @returns [Float] A finite duration of at least `0.0`, defaulting to `0.0` when unspecified or null.
Expand Down
9 changes: 8 additions & 1 deletion lib/presently/slide_renderer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,14 @@ def render(builder, slide, extra_class: nil)
classes = classes.compact.join(" ")
path = Stylesheet.encode_path(slide.path)

builder.tag(:div, class: "slide-surface", data: {template: slide.template}) do
background = slide.background
if background
# Escape the CSS string independently of the builder's HTML escaping:
url = background.gsub(/["\\\x00-\x1f\x7f]/){|character| "\\#{character.ord.to_s(16)} "}
style = %(background-image: url("#{url}"); background-size: #{slide.background_size};)
end

builder.tag(:div, class: "slide-surface", data: {template: slide.template, background: background}, style: style) do
builder.tag(:div, class: classes, data: {template: slide.template}, "data-slide-path": path) do
builder.raw(html)

Expand Down
6 changes: 3 additions & 3 deletions public/_components/.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
"version": null,
"source": ".",
"files": {
"Presently.js": "552a41737db0e67cde2f4555cf686ddb6a5de8da28c64d0404b8d7b2c10152c9",
"Presently.js": "1dc1a68b5a16bf0032d6f57bfb405dff47147fee605ce23172986926475fdd4d",
"Presently/CodeFocus.js": "05fb95308f012cab92655671050279149fc4aa683450932e27fc77bd51f8ce29",
"Presently/Scripts.js": "c2a005e2dc7e938a5ca32b7a2177331ad0cea6aa9e7496ffe2810b9561291726",
"Presently/Slide.js": "0d67e83eff558aee8ae829a2e526dc4d583f9c0753db156c531b57e32d271ead",
"Presently/SlideRendering.js": "935ce8ceb65800146cfdc734d93d764268d6077821199721b6d1bcd31b596113"
"Presently/SlideRendering.js": "4a5e20393795fea7ebb198c7e10e074ec8f4c98fba6f53a96a67f1891723fc85"
}
},
"@socketry/syntax": {
Expand Down Expand Up @@ -126,5 +126,5 @@
}
}
},
"digest": "b12ebf93a8c386fdd2b7861c43c602a1313b48decab5521775abc03c6e8ff2a4"
"digest": "2d6f6dd1fb5ef406a10a631dcd3f52899d13ff0daef0452b207b83184eab7194"
}
2 changes: 1 addition & 1 deletion public/_components/@socketry/presently/Presently.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export {Slide, SlideBuilder, SlideContext, SlideElements} from './Presently/Slide.js';
export {applyCodeFocus} from './Presently/CodeFocus.js';
export {SlideRendering} from './Presently/SlideRendering.js';
export {SlideRendering, prepareSlideBackgrounds} from './Presently/SlideRendering.js';
export {runAllSlideScripts, runScript} from './Presently/Scripts.js';
17 changes: 14 additions & 3 deletions public/_components/@socketry/presently/Presently/SlideRendering.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,19 @@ import {runScript} from './Scripts.js';

const SLIDE_CHANGE_EVENT = 'presently:slide:change';

async function prepareSlides() {
await Syntax.highlight();
// Wait for background images to decode before capturing a transition or export.
// A missing or invalid image must not prevent the slides from becoming ready.
export async function prepareSlideBackgrounds(root = document) {
const urls = new Set(Array.from(root.querySelectorAll('.slide-surface[data-background]'), surface => surface.dataset.background));
await Promise.all(Array.from(urls, async url => {
const image = new Image();
image.src = url;
await image.decode().catch(() => {});
}));
}

async function prepareSlides(view) {
await Promise.all([Syntax.highlight(), prepareSlideBackgrounds(view)]);
await applyCodeFocus();
}

Expand Down Expand Up @@ -42,7 +53,7 @@ export class SlideRendering {
if (slide) this.#slides.push(slide);
});

await prepareSlides();
await prepareSlides(this.#view);
return !this.#disposed;
}

Expand Down
5 changes: 4 additions & 1 deletion public/_static/slides.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
place-items: center;
container-type: size;
background: var(--slide-background);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}

.slide {
Expand Down Expand Up @@ -159,7 +162,7 @@

/* Section template */
.slide-surface[data-template="section"] {
background: linear-gradient(135deg, var(--accent), var(--surface-light));
background-image: linear-gradient(135deg, var(--accent), var(--surface-light));
}

.section-template {
Expand Down
9 changes: 5 additions & 4 deletions public/export.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {applyCodeFocus, runAllSlideScripts} from '@socketry/presently';
import {applyCodeFocus, runAllSlideScripts, prepareSlideBackgrounds} from '@socketry/presently';
import Syntax from '@socketry/syntax';

// Wait for two animation frames, ensuring the browser has processed all pending
Expand All @@ -9,15 +9,16 @@ function waitForRender() {
}

async function main() {
// 1. Kick off syntax highlighting and font loading concurrently.
// 1. Kick off syntax highlighting, background decoding, and font loading concurrently.
// Slide scripts are synchronous and don't depend on either, so run them now.
const syntaxDone = Syntax.highlight();
const backgroundsDone = prepareSlideBackgrounds();

// 2. Run slide scripts (synchronous in export mode — sets visibility instantly).
runAllSlideScripts({animated: false});

// 3. Wait for syntax and fonts to finish before applying focus.
await Promise.all([syntaxDone, document.fonts.ready]);
// 3. Wait for syntax, backgrounds, and fonts to finish before applying focus.
await Promise.all([syntaxDone, backgroundsDone, document.fonts.ready]);

// 4. Apply code focus now that syntax-code elements are ready.
await applyCodeFocus();
Expand Down
Loading
Loading