feat(export): one-click PNG copy and download of the plan

Captures the full timeline (not just the scrolled viewport) by expanding
the grid to content width during render; clipboard write stays inside the
user gesture so Safari accepts it, with download as the documented fallback.
This commit is contained in:
Julien Calixte
2026-06-17 01:30:03 +02:00
parent ca58ccb245
commit 2173683535
2 changed files with 134 additions and 3 deletions

View File

@@ -1,9 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
import { useMacroplan } from './composables/useMacroplan' import { useMacroplan } from './composables/useMacroplan'
import { usePngExport, exportFilename } from './composables/usePngExport'
import PlanEditor from './components/PlanEditor.vue' import PlanEditor from './components/PlanEditor.vue'
import MacroplanGrid from './components/MacroplanGrid.vue' import MacroplanGrid from './components/MacroplanGrid.vue'
const { source, plan, error, resetToSample } = useMacroplan() const { source, plan, error, resetToSample } = useMacroplan()
const { busy, toast, copyPng, downloadPng } = usePngExport()
const exportRoot = ref<HTMLElement>()
</script> </script>
<template> <template>
@@ -16,6 +21,23 @@ const { source, plan, error, resetToSample } = useMacroplan()
A week-granular, learning-oriented record of what we committed to deliver. A week-granular, learning-oriented record of what we committed to deliver.
</p> </p>
</div> </div>
<button
class="btn btn-ghost btn-sm"
:disabled="!plan || busy"
title="Copy the rendered plan to the clipboard as a PNG"
@click="copyPng(exportRoot)"
>
<span v-if="busy" class="loading loading-spinner loading-xs"></span>
Copy PNG
</button>
<button
class="btn btn-ghost btn-sm"
:disabled="!plan || busy"
title="Download the rendered plan as a PNG"
@click="downloadPng(exportRoot, exportFilename(plan?.title ?? ''))"
>
Download
</button>
<button class="btn btn-ghost btn-sm" @click="resetToSample">Reset to sample</button> <button class="btn btn-ghost btn-sm" @click="resetToSample">Reset to sample</button>
</header> </header>
@@ -30,10 +52,34 @@ const { source, plan, error, resetToSample } = useMacroplan()
</section> </section>
<section class="min-h-0 flex-1 overflow-auto p-4"> <section class="min-h-0 flex-1 overflow-auto p-4">
<h2 v-if="plan" class="mb-3 text-sm font-semibold text-base-content/70">{{ plan.title }}</h2> <div v-if="plan" ref="exportRoot" class="export-root">
<MacroplanGrid v-if="plan" :plan="plan" /> <h2 class="mb-3 text-sm font-semibold text-base-content/70">{{ plan.title }}</h2>
<p v-else class="text-sm text-base-content/60">Nothing to render yet fix the source on the left.</p> <MacroplanGrid :plan="plan" />
</div>
<p v-else class="text-sm text-base-content/60">
Nothing to render yet fix the source on the left.
</p>
</section> </section>
</main> </main>
<div v-if="toast" class="toast toast-end">
<div class="alert" :class="toast.kind === 'ok' ? 'alert-success' : 'alert-error'">
<span>{{ toast.text }}</span>
</div>
</div>
</div> </div>
</template> </template>
<style scoped>
/* While exporting, grow to the timeline's full width and un-clip the grid so the
captured PNG shows every week column (not just the on-screen scroll window).
The base-200 frame + padding give the snapshot a little breathing room. */
.export-root.exporting {
width: max-content;
padding: 1rem 1.25rem;
background: var(--color-base-200);
}
.export-root.exporting :deep(.macroplan) {
overflow: visible;
}
</style>

View File

@@ -0,0 +1,85 @@
import { ref } from 'vue'
type Toast = { kind: 'ok' | 'err'; text: string } | null
/** Slugified, stable download name derived from the plan title. */
export function exportFilename(title: string): string {
const slug = title
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
return `macroplan-${slug || 'plan'}.png`
}
/**
* Client-side PNG export of a rendered Macroplan (C6 / F7). Captures the full
* grid — not just the scrolled viewport — by expanding the target to its
* content width for the duration of the render, then copies to the clipboard
* or downloads. html-to-image is dynamically imported so it stays out of the
* initial bundle (like Shiki in the editor).
*/
export function usePngExport() {
const busy = ref(false)
const toast = ref<Toast>(null)
let timer: ReturnType<typeof setTimeout> | undefined
function flash(kind: 'ok' | 'err', text: string) {
toast.value = { kind, text }
if (timer) clearTimeout(timer)
timer = setTimeout(() => (toast.value = null), 3000)
}
async function render(el: HTMLElement): Promise<Blob> {
const { toBlob } = await import('html-to-image')
// `exporting` expands the wrapper to max-content and un-clips the grid so the
// whole timeline is captured even when it scrolls horizontally on screen.
el.classList.add('exporting')
// reading layout flushes the class's style changes before we measure
const width = el.scrollWidth
const height = el.scrollHeight
try {
const blob = await toBlob(el, { width, height, pixelRatio: 2, cacheBust: true })
if (!blob) throw new Error('renderer returned no image')
return blob
} finally {
el.classList.remove('exporting')
}
}
async function copyPng(el?: HTMLElement | null) {
if (!el || busy.value) return
busy.value = true
try {
// ClipboardItem accepts a Promise<Blob>, so the async render stays inside
// the user-gesture window — Safari rejects a write started after an await.
await navigator.clipboard.write([new ClipboardItem({ 'image/png': render(el) })])
flash('ok', 'PNG copied to clipboard')
} catch {
flash('err', 'Couldnt copy — use Download instead')
} finally {
busy.value = false
}
}
async function downloadPng(el?: HTMLElement | null, filename = 'macroplan.png') {
if (!el || busy.value) return
busy.value = true
try {
const blob = await render(el)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
flash('ok', 'PNG downloaded')
} catch {
flash('err', 'Export failed — please retry')
} finally {
busy.value = false
}
}
return { busy, toast, copyPng, downloadPng }
}