Compare commits
3 Commits
ead0d3158a
...
f37122eea1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f37122eea1 | ||
|
|
2173683535 | ||
|
|
ca58ccb245 |
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"daisyui": "^5.5.23",
|
"daisyui": "^5.5.23",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"shiki": "^4.2.0",
|
"shiki": "^4.2.0",
|
||||||
"smol-toml": "^1.6.1",
|
"smol-toml": "^1.6.1",
|
||||||
"vue": "^3.5.34"
|
"vue": "^3.5.34"
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
|||||||
daisyui:
|
daisyui:
|
||||||
specifier: ^5.5.23
|
specifier: ^5.5.23
|
||||||
version: 5.5.23
|
version: 5.5.23
|
||||||
|
html-to-image:
|
||||||
|
specifier: ^1.11.13
|
||||||
|
version: 1.11.13
|
||||||
shiki:
|
shiki:
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
@@ -638,6 +641,9 @@ packages:
|
|||||||
hast-util-whitespace@3.0.0:
|
hast-util-whitespace@3.0.0:
|
||||||
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
||||||
|
|
||||||
|
html-to-image@1.11.13:
|
||||||
|
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||||
|
|
||||||
html-void-elements@3.0.0:
|
html-void-elements@3.0.0:
|
||||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||||
|
|
||||||
@@ -1661,6 +1667,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
|
|
||||||
|
html-to-image@1.11.13: {}
|
||||||
|
|
||||||
html-void-elements@3.0.0: {}
|
html-void-elements@3.0.0: {}
|
||||||
|
|
||||||
ini@1.3.8: {}
|
ini@1.3.8: {}
|
||||||
|
|||||||
52
src/App.vue
52
src/App.vue
@@ -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>
|
||||||
|
|||||||
17
src/composables/usePngExport.test.ts
Normal file
17
src/composables/usePngExport.test.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { exportFilename } from './usePngExport'
|
||||||
|
|
||||||
|
describe('exportFilename', () => {
|
||||||
|
it('slugifies the plan title into a .png name', () => {
|
||||||
|
expect(exportFilename('Q3 — Checkout revamp')).toBe('macroplan-q3-checkout-revamp.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collapses runs of punctuation/space and trims edge dashes', () => {
|
||||||
|
expect(exportFilename(' Hello, World!! ')).toBe('macroplan-hello-world.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to a generic name when the title has no usable characters', () => {
|
||||||
|
expect(exportFilename('')).toBe('macroplan-plan.png')
|
||||||
|
expect(exportFilename('—— ··')).toBe('macroplan-plan.png')
|
||||||
|
})
|
||||||
|
})
|
||||||
85
src/composables/usePngExport.ts
Normal file
85
src/composables/usePngExport.ts
Normal 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', 'Couldn’t 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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user