mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 11:07:04 +00:00
changes
This commit is contained in:
1 parent
8b83f80d52
commit
72d698e427
16 files changed
+922
-199
No files matched your search
@@ -0,0 +1,67 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import type { Device } from "../devices";
|
||||
import type { Book } from "../model/book";
|
||||
import { CoverArt } from "./CoverArt";
|
||||
|
||||
interface DeviceFrameProps {
|
||||
device: Device;
|
||||
eyebrow?: ReactNode;
|
||||
title?: ReactNode;
|
||||
html?: string;
|
||||
cover?: Book;
|
||||
}
|
||||
|
||||
function frameVars(device: Device): CSSProperties {
|
||||
return {
|
||||
"--dv-bg": device.bg,
|
||||
"--dv-ink": device.ink,
|
||||
"--dv-fs": device.fontSize,
|
||||
"--dv-lh": device.lineHeight,
|
||||
"--dv-mx": device.marginX,
|
||||
"--dv-my": device.marginY,
|
||||
"--dv-bezel": device.bezel,
|
||||
"--dv-bezel-w": device.bezelWidth,
|
||||
"--dv-radius": device.radius,
|
||||
"--dv-aspect": device.aspect,
|
||||
} as CSSProperties;
|
||||
}
|
||||
|
||||
function StatusBar() {
|
||||
return (
|
||||
<div className="device-statusbar">
|
||||
<span className="device-clock">9:41</span>
|
||||
<span className="device-batt" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceFrame({ device, eyebrow, title, html, cover }: DeviceFrameProps) {
|
||||
return (
|
||||
<div className="device-stage">
|
||||
<div className="device-frame" data-kind={device.kind} style={frameVars(device)}>
|
||||
<div className="device-screen">
|
||||
{device.kind === "tablet" && <StatusBar />}
|
||||
<div className="device-content">
|
||||
{cover ? (
|
||||
<CoverArt book={cover} />
|
||||
) : (
|
||||
<>
|
||||
<div className="device-opener">
|
||||
{eyebrow && <div className="device-eyebrow">{eyebrow}</div>}
|
||||
{title && <div className="device-title">{title}</div>}
|
||||
</div>
|
||||
<div className="device-body" dangerouslySetInnerHTML={{ __html: html ?? "" }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{device.kind === "eink" && (
|
||||
<div className="device-footer">
|
||||
<span className="device-foot-title">{cover ? device.label : title}</span>
|
||||
<span className="device-batt" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+114
-42
@@ -1,12 +1,15 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { generateHTML } from "@tiptap/core";
|
||||
import { COVER_ID, useBook } from "../store/useBook";
|
||||
import { type TrimSize, bodyNumber, chapterKind } from "../model/book";
|
||||
import { type Chapter, type TrimSize, bodyNumber, chapterKind } from "../model/book";
|
||||
import { editorExtensions } from "../editor/extensions";
|
||||
import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
|
||||
import { compilePdf, isDesktop } from "../ipc";
|
||||
import { DEVICES, type Device, findDevice } from "../devices";
|
||||
import { usePreviewMode } from "../store/usePreviewMode";
|
||||
import { PdfPreview } from "./PdfPreview";
|
||||
import { CoverArt } from "./CoverArt";
|
||||
import { DeviceFrame } from "./DeviceFrame";
|
||||
|
||||
const TRIMS: { value: TrimSize; label: string }[] = [
|
||||
{ value: "6x9", label: "6 × 9 in" },
|
||||
@@ -15,26 +18,77 @@ const TRIMS: { value: TrimSize; label: string }[] = [
|
||||
{ value: "a5", label: "A5" },
|
||||
];
|
||||
|
||||
function TrimSelect() {
|
||||
function PreviewSelect() {
|
||||
const trim = useBook((s) => s.book?.settings.trim ?? "6x9");
|
||||
const setSettings = useBook((s) => s.setSettings);
|
||||
const mode = usePreviewMode((s) => s.mode);
|
||||
const setMode = usePreviewMode((s) => s.setMode);
|
||||
const value = mode === "print" ? trim : mode;
|
||||
return (
|
||||
<select
|
||||
className="trim-select"
|
||||
value={trim}
|
||||
onChange={(e) => setSettings({ trim: e.target.value as TrimSize })}
|
||||
title="Trim size"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (findDevice(v)) {
|
||||
setMode(v as Device["id"]);
|
||||
} else {
|
||||
setSettings({ trim: v as TrimSize });
|
||||
setMode("print");
|
||||
}
|
||||
}}
|
||||
title="Preview format"
|
||||
>
|
||||
{TRIMS.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
<optgroup label="Print">
|
||||
{TRIMS.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Devices">
|
||||
{DEVICES.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function DockHead({ meta }: { meta?: ReactNode }) {
|
||||
return (
|
||||
<div className="dock-head">
|
||||
<span className="label">Preview</span>
|
||||
<div className="dock-tools">
|
||||
{meta}
|
||||
<PreviewSelect />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function chapterEyebrow(chapter: Chapter, chapters: Chapter[], idx: number): string {
|
||||
const kind = chapterKind(chapter);
|
||||
return kind === "body" ? `Chapter ${bodyNumber(chapters, idx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
|
||||
}
|
||||
|
||||
function useActiveChapter() {
|
||||
const book = useBook((s) => s.book);
|
||||
const activeChapterId = useBook((s) => s.activeChapterId);
|
||||
const chapters = book?.chapters ?? [];
|
||||
const idx = chapters.findIndex((c) => c.id === activeChapterId);
|
||||
const chapter = chapters[idx] ?? chapters[0];
|
||||
const html = useMemo(() => (chapter ? generateHTML(chapter.content, editorExtensions) : ""), [chapter]);
|
||||
const eyebrow = chapter ? chapterEyebrow(chapter, chapters, idx) : "";
|
||||
return { book, coverActive: activeChapterId === COVER_ID, chapters, idx, chapter, html, eyebrow };
|
||||
}
|
||||
|
||||
export function Dock() {
|
||||
const mode = usePreviewMode((s) => s.mode);
|
||||
const device = findDevice(mode);
|
||||
if (device) return <DeviceDock device={device} />;
|
||||
return isDesktop ? <PdfDock /> : <HtmlDock />;
|
||||
}
|
||||
|
||||
@@ -67,13 +121,7 @@ function PdfDock() {
|
||||
|
||||
return (
|
||||
<section className="dock">
|
||||
<div className="dock-head">
|
||||
<span className="label">Preview</span>
|
||||
<div className="dock-tools">
|
||||
{busy && <span className="meta">updating…</span>}
|
||||
<TrimSelect />
|
||||
</div>
|
||||
</div>
|
||||
<DockHead meta={busy ? <span className="meta">updating…</span> : null} />
|
||||
{error ? (
|
||||
<pre className="dock-error">{error}</pre>
|
||||
) : pdf ? (
|
||||
@@ -86,26 +134,14 @@ function PdfDock() {
|
||||
}
|
||||
|
||||
function HtmlDock() {
|
||||
const book = useBook((s) => s.book);
|
||||
const activeChapterId = useBook((s) => s.activeChapterId);
|
||||
const coverActive = activeChapterId === COVER_ID;
|
||||
const chapters = book?.chapters ?? [];
|
||||
const idx = chapters.findIndex((c) => c.id === activeChapterId);
|
||||
const chapter = chapters[idx] ?? chapters[0];
|
||||
const html = useMemo(() => (chapter ? generateHTML(chapter.content, editorExtensions) : ""), [chapter]);
|
||||
const { book, coverActive, chapters, idx, chapter, html, eyebrow } = useActiveChapter();
|
||||
|
||||
if (!book) return null;
|
||||
|
||||
if (coverActive) {
|
||||
return (
|
||||
<section className="dock">
|
||||
<div className="dock-head">
|
||||
<span className="label">Preview</span>
|
||||
<div className="dock-tools">
|
||||
<span className="meta">Cover</span>
|
||||
<TrimSelect />
|
||||
</div>
|
||||
</div>
|
||||
<DockHead meta={<span className="meta">Cover</span>} />
|
||||
<div className="cover-stage">
|
||||
<CoverArt book={book} />
|
||||
</div>
|
||||
@@ -115,21 +151,15 @@ function HtmlDock() {
|
||||
|
||||
if (!chapter) return null;
|
||||
|
||||
const kind = chapterKind(chapter);
|
||||
const eyebrow =
|
||||
kind === "body" ? `Chapter ${bodyNumber(chapters, idx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
|
||||
|
||||
return (
|
||||
<section className="dock">
|
||||
<div className="dock-head">
|
||||
<span className="label">Preview</span>
|
||||
<div className="dock-tools">
|
||||
<DockHead
|
||||
meta={
|
||||
<span className="meta">
|
||||
{idx + 1} of {chapters.length}
|
||||
</span>
|
||||
<TrimSelect />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="page">
|
||||
<div className="p-opener">
|
||||
<div className="p-num">{eyebrow}</div>
|
||||
@@ -141,3 +171,45 @@ function HtmlDock() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceDock({ device }: { device: Device }) {
|
||||
const { book, coverActive, chapters, idx, chapter, html, eyebrow } = useActiveChapter();
|
||||
|
||||
if (!book) return null;
|
||||
|
||||
if (coverActive) {
|
||||
return (
|
||||
<section className="dock">
|
||||
<DockHead meta={<span className="meta">Cover</span>} />
|
||||
<DeviceFrame device={device} cover={book} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!chapter) {
|
||||
return (
|
||||
<section className="dock">
|
||||
<DockHead />
|
||||
<div className="dock-empty">No chapter selected.</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="dock">
|
||||
<DockHead
|
||||
meta={
|
||||
<span className="meta">
|
||||
{idx + 1} of {chapters.length}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DeviceFrame
|
||||
device={device}
|
||||
eyebrow={eyebrow}
|
||||
title={chapter.noTitle ? undefined : chapter.title || "Untitled"}
|
||||
html={html}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
export type DeviceId = "kindle" | "kobo" | "ipad" | "iphone" | "android";
|
||||
export type PreviewMode = "print" | DeviceId;
|
||||
export type DeviceKind = "eink" | "phone" | "tablet";
|
||||
|
||||
export interface Device {
|
||||
id: DeviceId;
|
||||
label: string;
|
||||
kind: DeviceKind;
|
||||
bg: string;
|
||||
ink: string;
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
marginX: number;
|
||||
marginY: number;
|
||||
aspect: number;
|
||||
bezel: string;
|
||||
bezelWidth: number;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
export const DEVICES: Device[] = [
|
||||
{
|
||||
id: "kindle",
|
||||
label: "Kindle Paperwhite",
|
||||
kind: "eink",
|
||||
bg: "#f4f3ee",
|
||||
ink: "#2b2b2b",
|
||||
fontSize: 16,
|
||||
lineHeight: 1.55,
|
||||
marginX: 34,
|
||||
marginY: 40,
|
||||
aspect: 1240 / 1680,
|
||||
bezel: "#1c1c1c",
|
||||
bezelWidth: 18,
|
||||
radius: 14,
|
||||
},
|
||||
{
|
||||
id: "kobo",
|
||||
label: "Kobo",
|
||||
kind: "eink",
|
||||
bg: "#f6f5f0",
|
||||
ink: "#262626",
|
||||
fontSize: 16,
|
||||
lineHeight: 1.6,
|
||||
marginX: 34,
|
||||
marginY: 40,
|
||||
aspect: 1264 / 1680,
|
||||
bezel: "#2a2a2a",
|
||||
bezelWidth: 16,
|
||||
radius: 16,
|
||||
},
|
||||
{
|
||||
id: "ipad",
|
||||
label: "iPad",
|
||||
kind: "tablet",
|
||||
bg: "#fbfaf7",
|
||||
ink: "#1d1d1f",
|
||||
fontSize: 18,
|
||||
lineHeight: 1.65,
|
||||
marginX: 56,
|
||||
marginY: 56,
|
||||
aspect: 1640 / 2360,
|
||||
bezel: "#3a3a3c",
|
||||
bezelWidth: 20,
|
||||
radius: 28,
|
||||
},
|
||||
{
|
||||
id: "iphone",
|
||||
label: "iPhone",
|
||||
kind: "phone",
|
||||
bg: "#ffffff",
|
||||
ink: "#1d1d1f",
|
||||
fontSize: 17,
|
||||
lineHeight: 1.6,
|
||||
marginX: 26,
|
||||
marginY: 30,
|
||||
aspect: 1179 / 2556,
|
||||
bezel: "#1a1a1c",
|
||||
bezelWidth: 12,
|
||||
radius: 44,
|
||||
},
|
||||
{
|
||||
id: "android",
|
||||
label: "Android phone",
|
||||
kind: "phone",
|
||||
bg: "#ffffff",
|
||||
ink: "#202124",
|
||||
fontSize: 16,
|
||||
lineHeight: 1.6,
|
||||
marginX: 24,
|
||||
marginY: 28,
|
||||
aspect: 1080 / 2400,
|
||||
bezel: "#0f0f0f",
|
||||
bezelWidth: 11,
|
||||
radius: 36,
|
||||
},
|
||||
];
|
||||
|
||||
const DEVICE_MAP = new Map<string, Device>(DEVICES.map((d) => [d.id, d]));
|
||||
|
||||
const KEY = "margin-preview-mode";
|
||||
|
||||
export function findDevice(id: string): Device | undefined {
|
||||
return DEVICE_MAP.get(id);
|
||||
}
|
||||
|
||||
export function initialPreviewMode(): PreviewMode {
|
||||
const saved = localStorage.getItem(KEY);
|
||||
if (saved === "print" || (saved && DEVICE_MAP.has(saved))) return saved as PreviewMode;
|
||||
return "print";
|
||||
}
|
||||
|
||||
export function persistPreviewMode(mode: PreviewMode) {
|
||||
localStorage.setItem(KEY, mode);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { create } from "zustand";
|
||||
import { type PreviewMode, initialPreviewMode, persistPreviewMode } from "../devices";
|
||||
|
||||
interface PreviewState {
|
||||
mode: PreviewMode;
|
||||
setMode: (mode: PreviewMode) => void;
|
||||
}
|
||||
|
||||
export const usePreviewMode = create<PreviewState>((set) => ({
|
||||
mode: initialPreviewMode(),
|
||||
setMode: (mode) => {
|
||||
persistPreviewMode(mode);
|
||||
set({ mode });
|
||||
},
|
||||
}));
|
||||
@@ -1992,3 +1992,194 @@ body.resizing {
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.device-stage {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
padding-block: 6px;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.device-frame {
|
||||
--dv-scale: calc(100cqi / 380);
|
||||
width: 100%;
|
||||
padding: calc(var(--dv-bezel-w) * var(--dv-scale));
|
||||
background: var(--dv-bezel);
|
||||
border-radius: calc(var(--dv-radius) * var(--dv-scale));
|
||||
box-shadow: var(--shadow-page);
|
||||
}
|
||||
|
||||
.device-screen {
|
||||
aspect-ratio: var(--dv-aspect);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--dv-bg);
|
||||
color: var(--dv-ink);
|
||||
border-radius: calc(max(0px, (var(--dv-radius) - var(--dv-bezel-w)) * var(--dv-scale)));
|
||||
}
|
||||
|
||||
.device-statusbar {
|
||||
flex: none;
|
||||
height: calc(30 * var(--dv-scale));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 calc(18 * var(--dv-scale));
|
||||
font-family: var(--font-ui);
|
||||
font-size: calc(11 * var(--dv-scale));
|
||||
font-weight: 600;
|
||||
color: var(--dv-ink);
|
||||
}
|
||||
|
||||
.device-clock {
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.device-batt {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: calc(22 * var(--dv-scale));
|
||||
height: calc(11 * var(--dv-scale));
|
||||
border: calc(1.4 * var(--dv-scale)) solid var(--dv-ink);
|
||||
border-radius: calc(3 * var(--dv-scale));
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.device-batt::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: calc(1.5 * var(--dv-scale));
|
||||
bottom: calc(1.5 * var(--dv-scale));
|
||||
left: calc(1.5 * var(--dv-scale));
|
||||
width: 55%;
|
||||
background: var(--dv-ink);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.device-batt::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: calc(-3 * var(--dv-scale));
|
||||
top: 30%;
|
||||
height: 40%;
|
||||
width: calc(2 * var(--dv-scale));
|
||||
border-radius: 1px;
|
||||
background: var(--dv-ink);
|
||||
}
|
||||
|
||||
.device-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: calc(var(--dv-my) * var(--dv-scale)) calc(var(--dv-mx) * var(--dv-scale));
|
||||
font-family: var(--font-book);
|
||||
font-size: calc(var(--dv-fs) * var(--dv-scale));
|
||||
line-height: var(--dv-lh);
|
||||
}
|
||||
|
||||
.device-frame[data-kind="eink"] .device-content::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.device-opener {
|
||||
text-align: center;
|
||||
margin-bottom: calc(20 * var(--dv-scale));
|
||||
}
|
||||
|
||||
.device-eyebrow {
|
||||
font-family: var(--font-ui);
|
||||
font-size: calc(10 * var(--dv-scale));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.5;
|
||||
margin-bottom: calc(8 * var(--dv-scale));
|
||||
}
|
||||
|
||||
.device-title {
|
||||
font-family: var(--font-book);
|
||||
font-weight: 500;
|
||||
font-size: calc(24 * var(--dv-scale));
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.device-body p {
|
||||
font-family: var(--font-book);
|
||||
hyphens: auto;
|
||||
margin: 0 0 0.8em;
|
||||
color: var(--dv-ink);
|
||||
}
|
||||
|
||||
.device-body p[data-indent="true"] {
|
||||
text-indent: 1.2em;
|
||||
}
|
||||
|
||||
.device-frame[data-kind="eink"] .device-body p {
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.device-frame[data-kind="phone"] .device-body p,
|
||||
.device-frame[data-kind="tablet"] .device-body p {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.device-body h1,
|
||||
.device-body h2,
|
||||
.device-body h3 {
|
||||
font-family: var(--font-book);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin: 1.2em 0 0.5em;
|
||||
}
|
||||
|
||||
.device-body blockquote {
|
||||
margin: 0 0 0.8em;
|
||||
padding-left: 1em;
|
||||
border-left: 2px solid currentColor;
|
||||
font-style: italic;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.device-body hr {
|
||||
border: none;
|
||||
border-top: 1px solid currentColor;
|
||||
width: calc(40 * var(--dv-scale));
|
||||
margin: calc(16 * var(--dv-scale)) auto;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.device-body img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 0.8em auto;
|
||||
}
|
||||
|
||||
.device-footer {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: calc(10 * var(--dv-scale));
|
||||
padding: calc(8 * var(--dv-scale)) calc(var(--dv-mx) * var(--dv-scale));
|
||||
font-family: var(--font-ui);
|
||||
font-size: calc(11 * var(--dv-scale));
|
||||
color: var(--dv-ink);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.device-foot-title {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.device-content .cover-art {
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
Reference in new issue
Block a user