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
11 changes: 7 additions & 4 deletions engine/app/assets/stylesheets/coplan/deck.css
Original file line number Diff line number Diff line change
Expand Up @@ -473,11 +473,14 @@
object-fit: contain;
}

/* ---- presenting — the deck takes the screen ---------------------------
/* ---- presenting — the deck takes the window ---------------------------
Present mode (coplan--deck-presenter) shows the deck as a top-layer
popover sized to the largest 16:9 canvas that fits the viewport — the
deck stays the size container, so every cqi measurement above scales to
the screen with no presentation-specific typography. Top layer matters:
popover sized to the largest 16:9 canvas that fits the viewport. That
is the whole mechanism, in a window and under native fullscreen alike:
viewport units mean pressing `f` needs no presentation-specific rule,
the canvas just grows into the screen. The deck stays the size
container, so every cqi measurement above scales with it and no
presentation-specific typography is needed. Top layer matters:
fixed positioning alone resolves against any backdrop-filter ancestor
(the host's glass card), while top-layer elements always position
against the viewport. The spread shadow blacks out the letterbox bars
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,20 @@ const DRAG_SLOP = 4
/*
* coplan--deck-presenter
*
* Present mode: the deck takes the screen as one 16:9 canvas — exactly what
* a Zoom or Meet screen-share needs. One slide shows at a time; arrows,
* space, page keys, and clicks advance; Escape (or leaving native
* fullscreen) ends the show. Native fullscreen is requested on this
* wrapper, with the fixed-overlay CSS as the fallback when the browser
* refuses, so presenting works either way.
* Present mode: the deck takes the browser window as one 16:9 canvas. One
* slide shows at a time; arrows, space, page keys, and clicks advance;
* Escape ends the show.
*
* The window, not the screen, is the default on purpose. Presenting over
* Zoom or Meet means sharing a window, and on macOS native fullscreen moves
* the window onto its own Space — where screen-share pickers can no longer
* see it at all. Taking the screen also buries the call controls the
* presenter is talking through. So the show fills the window (the top-layer
* popover in _promoteDeck is what makes that airtight), and `f` takes the
* whole screen for the times there's a projector and no call. Fullscreen is
* a mode within the show, not the show itself: dropping out of it — by `f`,
* by Escape, by the browser's own chrome — leaves the deck presenting in
* the window.
*
* Two gestures mark up a slide without leaving the show. Dragging across
* text highlights it in the deck's accent — the room's eyes follow the
Expand Down Expand Up @@ -78,10 +86,9 @@ export default class extends Controller {
// Presenting borrows the URL fragment for #present-N; remember what
// was there (a heading deep link, a footnote) to give back on exit.
this._priorHash = resumed ? "" : window.location.hash
this._peeling = false
this._show(resumed ? Number(resumed[1]) - 1 : 0)
// _show stops the show itself on a slideless deck — don't take the
// screen for nothing.
// _show stops the show itself on a slideless deck — don't black out
// the window for nothing.
if (!this.presenting) return

// Move focus into the show. Clicking the Present button leaves the
Expand All @@ -93,12 +100,18 @@ export default class extends Controller {
deck.tabIndex = -1
deck.focus({ preventScroll: true })
}
}

// `f` mid-show. Fullscreen is asked for on the wrapper rather than the
// deck so the deck stays free to be a popover; the top-layer popover is
// what fills the space either way, and re-promoting is what keeps it
// visible — the fullscreen wrapper enters the top layer ABOVE the
// already-shown popover and would otherwise cover it.
_toggleFullscreen() {
if (document.fullscreenElement === this.element) return document.exitFullscreen().catch(() => {})
if (!this.element.requestFullscreen) return

// Native fullscreen when the browser grants it; the top-layer popover
// (see _promoteDeck) is what actually fills the screen either way.
// Re-promote once fullscreen resolves — the fullscreen wrapper enters
// the top layer above the already-shown popover and would cover it.
this.element.requestFullscreen?.().then(() => {
this.element.requestFullscreen().then(() => {
// The grant can outlive a fast Escape: if the show already ended,
// give the screen back instead of re-promoting a stopped deck.
if (!this.presenting) {
Expand Down Expand Up @@ -237,18 +250,28 @@ export default class extends Controller {
event.stopPropagation()
this.ink.toggle()
break
case "f":
// Full screen. For a projector in a room; a call wants the window
// (see the note at the top of this file), which is what the show
// gives back when this toggles off.
event.preventDefault()
event.stopPropagation()
this._toggleFullscreen()
break
case "Escape":
event.preventDefault()
event.stopPropagation()
// Escape peels one layer at a time: whatever is visibly in front of
// the show goes first — a popover (a reference preview, a pinned
// thread), then the pen — and only a bare Escape ends the show. The
// browser may drop native fullscreen on the same keypress (that
// exit is uncancelable), so mark the peel: the resulting
// fullscreenchange is forgiven and the show continues on the
// top-layer fallback.
if (this._dismissForeignPopovers() || this._stowPen()) {
if (document.fullscreenElement === this.element) this._peeling = true
// thread), then the pen, then the screen — and only a bare Escape
// ends the show. Giving the screen back is its own step because
// full screen is the one layer the presenter took deliberately;
// Escape should undo that, not the whole show. (The browser drops
// fullscreen on this keypress anyway, uncancelably — exiting here
// just makes the same thing happen on purpose.)
if (this._dismissForeignPopovers() || this._stowPen()) return
if (document.fullscreenElement === this.element) {
document.exitFullscreen().catch(() => {})
return
}
this.stop()
Expand Down Expand Up @@ -390,13 +413,13 @@ export default class extends Controller {
_handleFullscreenChange() {
if (!this.presenting || document.fullscreenElement) return

// One exit is forgiven when Escape was consumed dismissing a popover —
// the keypress was aimed at the popover, not the show.
if (this._peeling) {
this._peeling = false
return
}
this.stop()
// Losing the screen no longer ends the show — the presenter is back to
// the window they started in, which is the shape a call wants. However
// it happened (the `f` toggle, Escape, the browser's own chrome), the
// one thing to check is that the deck is still in the top layer: the
// fullscreen wrapper leaving it is the moment a promotion could be
// dropped, and a closed popover is a blank page.
this._promoteDeck()
}

_typing(target) {
Expand Down
14 changes: 8 additions & 6 deletions engine/app/views/coplan/plans/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,13 @@
anchored to diagram labels get their marks (and pending ?thread=
deep links can resolve). %>
<div class="plan-layout__content">
<%# Presentations present: `p` or the button starts a fullscreen
show (deck_presenter_controller). The wrapper sits outside the
live-update swap target so a collaborator's edit landing
mid-show doesn't disconnect the presenter — and the toolbar sits
outside the text-selection content target below, because its
<%# Presentations present: `p` or the button fills the window with
the deck (deck_presenter_controller) — a window is what a call
can screen-share, and `f` takes the whole screen for a room.
The wrapper sits outside the live-update swap target so a
collaborator's edit landing mid-show doesn't disconnect the
presenter — and the toolbar sits outside the text-selection
content target below, because its
label is visible text and comment anchors count visible-text
occurrences under that target. %>
<% if @plan.presentation? %>
Expand All @@ -93,7 +95,7 @@
<%# The only place the show's keys are advertised before it
starts — mid-show there is no room for a legend, and the pen
is the one gesture nobody would guess. %>
<button type="button" class="deck-toolbar__present" data-action="coplan--deck-presenter#start" title="Present (p) — mid-show, drag to highlight, d for the pen">
<button type="button" class="deck-toolbar__present" data-action="coplan--deck-presenter#start" title="Present (p) — mid-show, f for full screen, drag to highlight, d for the pen">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 4.8a1 1 0 0 1 1.53-.85l11.1 7.2a1 1 0 0 1 0 1.7l-11.1 7.2A1 1 0 0 1 7 19.2z"/></svg>
Present <kbd>p</kbd>
</button>
Expand Down
54 changes: 51 additions & 3 deletions spec/system/deck_ux_spec.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
require "rails_helper"

# Browser-level coverage for the deck's pointer and navigation behavior:
# the two ways a presenter marks up a live slide (selecting text, and the
# pen), the Mermaid expand chip staying chip-sized on a slide, and the
# back-matter links jumping in place instead of refetching the page.
# the show filling the window rather than taking the screen (a call shares
# windows), the two ways a presenter marks up a live slide (selecting text,
# and the pen), the Mermaid expand chip staying chip-sized on a slide, and
# the back-matter links jumping in place instead of refetching the page.
RSpec.describe "Deck UX", type: :system do
let(:user) { create(:coplan_user, email: "presenter@example.com") }
let(:deck_type) { create(:plan_type, name: "Presentation", behavior: "presentation") }
Expand Down Expand Up @@ -96,7 +97,14 @@ def strokes_drawn
page.evaluate_script("window.__strokes")
end

# Wait for the deck itself before reaching for the button. This page
# renders Mermaid, so it settles well past Capybara's 2s default on a
# loaded CI runner — and the toolbar only exists once the deck does, so
# a bare click_button spends that whole default budget looking for a
# button the server hasn't sent yet. (The examples below that don't
# present already wait explicitly for the same reason.)
def start_show
expect(page).to have_css(".deck-slide", wait: 10)
click_button "Present"
expect(page).to have_css(".deck--presenting .deck-slide--current", wait: 5)
end
Expand Down Expand Up @@ -155,6 +163,46 @@ def attachments_on_screen?
send_keys(:space)
expect(checkbox).to be_checked
end

# A call shares a window; macOS moves a fullscreen window onto its own
# Space, where screen-share pickers can't see it. So starting the show
# must not take the screen — `f` is the presenter asking for it.
#
# Reached by its readable address rather than plan_path's /_/plans/<id>.
# That's the canonical URL — PlansController 301s the id form onto it —
# so it's the address a presenter actually opens. It also steps around
# a CI-only routing failure on the legacy id path that this example
# kept tripping (see the note on the issue filed alongside this).
it "fills the window without taking the screen, and takes it on f" do
visit "/#{plan.url_path}"
start_show
expect(page.evaluate_script("!!document.fullscreenElement")).to be(false)

# The window is the canvas either way: the deck is in the top layer,
# so no glass card ancestor can trap or cover it.
expect(page.evaluate_script(<<~JS)).to be(true)
(() => {
const deck = document.querySelector(".deck--presenting");
if (!deck.matches(":popover-open")) return false;
const box = deck.getBoundingClientRect();
const fits = Math.min(window.innerWidth, window.innerHeight * 16 / 9);
return Math.abs(box.width - fits) < 2;
})()
JS

send_keys("f")
expect(page).to have_css(".deck-presenter:fullscreen", wait: 5)

# Escape gives the screen back and the show carries on in the window —
# full screen is a layer to peel, not the show itself.
send_keys(:escape)
expect(page).to have_no_css(".deck-presenter:fullscreen", wait: 5)
expect(page).to have_css(".deck--presenting")
expect(current_slide).to eq("1")

send_keys(:escape)
expect(page).to have_no_css(".deck--presenting")
end
end

describe "the pen" do
Expand Down
Loading