feat: resolve all 22 items in Features and Bugs.txt and complete UI polish
This commit is contained in:
@@ -29,6 +29,13 @@ export interface SessionRow {
|
||||
projectId: string;
|
||||
title: string;
|
||||
kind: AISession["kind"];
|
||||
archived?: boolean | undefined;
|
||||
agentId?: string | undefined;
|
||||
providerId?: string | undefined;
|
||||
model?: string | undefined;
|
||||
messages?: AIMessage[] | undefined;
|
||||
suggestions?: AISuggestion[] | undefined;
|
||||
notes?: AINote[] | undefined;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastMessageAt: string | null;
|
||||
@@ -64,7 +71,7 @@ export function createInMemoryPersistence(): AISessionPersistence {
|
||||
}
|
||||
|
||||
/** A Dexie-backed IndexedDB persistence backend. */
|
||||
export function createIndexedDBPersistence(dbName = "pubscript"): AISessionPersistence {
|
||||
export function createIndexedDBPersistence(dbName = "pubscript_ai"): AISessionPersistence {
|
||||
const db = new Dexie(dbName);
|
||||
if (!db.tables.some((t) => t.name === "aiSessions")) {
|
||||
db.version(1).stores({ aiSessions: "&id, projectId, updatedAt" });
|
||||
@@ -110,9 +117,13 @@ export class AIStore {
|
||||
projectId: row.projectId,
|
||||
title: row.title,
|
||||
kind: row.kind,
|
||||
messages: [],
|
||||
suggestions: [],
|
||||
notes: [],
|
||||
archived: row.archived ?? false,
|
||||
agentId: row.agentId,
|
||||
providerId: row.providerId,
|
||||
model: row.model,
|
||||
messages: row.messages ?? [],
|
||||
suggestions: row.suggestions ?? [],
|
||||
notes: row.notes ?? [],
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
lastMessageAt: row.lastMessageAt,
|
||||
@@ -133,6 +144,13 @@ export class AIStore {
|
||||
projectId: session.projectId,
|
||||
title: session.title,
|
||||
kind: session.kind,
|
||||
archived: session.archived ?? false,
|
||||
agentId: session.agentId,
|
||||
providerId: session.providerId,
|
||||
model: session.model,
|
||||
messages: session.messages.map((m) => ({ ...m })),
|
||||
suggestions: session.suggestions.map((s) => ({ ...s })),
|
||||
notes: session.notes.map((n) => ({ ...n })),
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
lastMessageAt: session.lastMessageAt,
|
||||
@@ -140,14 +158,21 @@ export class AIStore {
|
||||
}
|
||||
|
||||
/** Create a new session and persist it. */
|
||||
async createSession(title?: string): Promise<AISession> {
|
||||
async createSession(
|
||||
title?: string,
|
||||
meta?: { agentId?: string; providerId?: string; model?: string; kind?: AISession["kind"] },
|
||||
): Promise<AISession> {
|
||||
await this.load();
|
||||
const now = nowIso();
|
||||
const session: AISession = {
|
||||
id: createUlid(),
|
||||
projectId: this.projectId,
|
||||
title: title ?? `New AI session ${this.sessions.size + 1}`,
|
||||
kind: "session",
|
||||
kind: meta?.kind ?? "session",
|
||||
archived: false,
|
||||
agentId: meta?.agentId,
|
||||
providerId: meta?.providerId,
|
||||
model: meta?.model,
|
||||
messages: [],
|
||||
suggestions: [],
|
||||
notes: [],
|
||||
@@ -160,6 +185,29 @@ export class AIStore {
|
||||
return this.clone(session);
|
||||
}
|
||||
|
||||
/** Update session metadata (title, archived, agentId, providerId, model). */
|
||||
async updateSessionMeta(
|
||||
sessionId: string,
|
||||
patch: {
|
||||
title?: string;
|
||||
archived?: boolean;
|
||||
agentId?: string;
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
},
|
||||
): Promise<AISession> {
|
||||
return this.withTransaction(async () => {
|
||||
const session = this.require(sessionId);
|
||||
if (patch.title !== undefined) session.title = patch.title;
|
||||
if (patch.archived !== undefined) session.archived = patch.archived;
|
||||
if (patch.agentId !== undefined) session.agentId = patch.agentId;
|
||||
if (patch.providerId !== undefined) session.providerId = patch.providerId;
|
||||
if (patch.model !== undefined) session.model = patch.model;
|
||||
session.updatedAt = nowIso();
|
||||
return this.clone(session);
|
||||
});
|
||||
}
|
||||
|
||||
/** Get a session by id. */
|
||||
async getSession(id: string): Promise<AISession | null> {
|
||||
await this.load();
|
||||
|
||||
@@ -123,6 +123,16 @@ export const storageLocal: StorageAdapter = {
|
||||
return p;
|
||||
},
|
||||
|
||||
async deleteProject(id: string): Promise<void> {
|
||||
await db.projects.delete(id);
|
||||
await db.books.where("projectId").equals(id).delete();
|
||||
await db.chapters.where("projectId").equals(id).delete();
|
||||
await db.entities.where("projectId").equals(id).delete();
|
||||
await db.boards.where("projectId").equals(id).delete();
|
||||
await db.plotBeats.where("projectId").equals(id).delete();
|
||||
await db.aiSessions.where("projectId").equals(id).delete();
|
||||
},
|
||||
|
||||
async createBook(b: { projectId: string; title: string }): Promise<Book> {
|
||||
const id = createUlid();
|
||||
const book: Book = { id, projectId: b.projectId, title: b.title, order: 0, chapters: [] };
|
||||
@@ -140,8 +150,16 @@ export const storageLocal: StorageAdapter = {
|
||||
return rows.sort((a, b) => a.order - b.order);
|
||||
},
|
||||
|
||||
async deleteChapter(id: string): Promise<void> {
|
||||
await db.chapters.delete(id);
|
||||
},
|
||||
|
||||
async saveDocument(doc: DocumentRef): Promise<void> {
|
||||
const existing = await db.chapters.get(doc.id);
|
||||
const existingCount =
|
||||
!existing && doc.bookId
|
||||
? await db.chapters.where("bookId").equals(doc.bookId).count()
|
||||
: 0;
|
||||
const base: Chapter = existing ?? {
|
||||
id: doc.id,
|
||||
bookId: doc.bookId ?? "",
|
||||
@@ -149,7 +167,7 @@ export const storageLocal: StorageAdapter = {
|
||||
title: doc.title,
|
||||
doc: doc.doc,
|
||||
rev: 0,
|
||||
order: 0,
|
||||
order: existingCount,
|
||||
meta: { wordCount: 0, scenes: [], updatedAt: nowIso() },
|
||||
};
|
||||
await db.chapters.put({
|
||||
|
||||
@@ -31,11 +31,13 @@ export interface StorageAdapter {
|
||||
listProjects(): Promise<ProjectMeta[]>;
|
||||
createProject(p: NewProject): Promise<Project>;
|
||||
getProject(id: string): Promise<Project>;
|
||||
deleteProject?(id: string): Promise<void>;
|
||||
|
||||
// books & chapters
|
||||
createBook(b: { projectId: string; title: string }): Promise<Book>;
|
||||
listBooks(projectId: string): Promise<Book[]>;
|
||||
listChapters(bookId: string): Promise<Chapter[]>;
|
||||
deleteChapter?(id: string): Promise<void>;
|
||||
|
||||
// content (debounced autosave)
|
||||
saveDocument(doc: DocumentRef): Promise<void>;
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface User {
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: Role;
|
||||
/** Whether the account is locked out (auth + admin API access). */
|
||||
disabled?: boolean | undefined;
|
||||
/** Optional, never required in v1. */
|
||||
passKey?: string;
|
||||
createdAt: string;
|
||||
@@ -313,6 +315,10 @@ export interface AISession {
|
||||
projectId: string;
|
||||
title: string;
|
||||
kind: SessionKind;
|
||||
archived?: boolean | undefined;
|
||||
agentId?: string | undefined;
|
||||
providerId?: string | undefined;
|
||||
model?: string | undefined;
|
||||
messages: AIMessage[];
|
||||
suggestions: AISuggestion[];
|
||||
notes: AINote[];
|
||||
|
||||
@@ -72,6 +72,11 @@ export function usePubScriptEditor(options: PubScriptEditorHookOptions = {}) {
|
||||
const editor = useEditor({
|
||||
editable,
|
||||
extensions: pubScriptExtensions,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "ps-editor-content",
|
||||
},
|
||||
},
|
||||
...(content !== undefined ? { content } : {}),
|
||||
...useEditorOptions,
|
||||
});
|
||||
|
||||
@@ -17,7 +17,14 @@
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
|
||||
import { Editor as TipTapEditor, getSchema, type Content } from "@tiptap/core";
|
||||
import {
|
||||
Editor as TipTapEditor,
|
||||
Extension,
|
||||
Mark,
|
||||
getSchema,
|
||||
mergeAttributes,
|
||||
type Content,
|
||||
} from "@tiptap/core";
|
||||
|
||||
import SceneBreakNode from "./nodes/SceneBreak.js";
|
||||
import FootnoteInline from "./nodes/FootnoteInline.js";
|
||||
@@ -37,6 +44,90 @@ import {
|
||||
colorAttribute,
|
||||
} from "./constants.js";
|
||||
|
||||
/**
|
||||
* Inline highlight mark (`<mark class="ps-mark-highlight">`).
|
||||
*/
|
||||
export const HighlightMark = Mark.create({
|
||||
name: "highlight",
|
||||
parseHTML() {
|
||||
return [{ tag: "mark" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["mark", mergeAttributes({ class: "ps-mark-highlight" }, HTMLAttributes), 0];
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Inline superscript mark (`<sup>`).
|
||||
*/
|
||||
export const SuperscriptMark = Mark.create({
|
||||
name: "superscript",
|
||||
excludes: "subscript",
|
||||
parseHTML() {
|
||||
return [{ tag: "sup" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["sup", HTMLAttributes, 0];
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Inline subscript mark (`<sub>`).
|
||||
*/
|
||||
export const SubscriptMark = Mark.create({
|
||||
name: "subscript",
|
||||
excludes: "superscript",
|
||||
parseHTML() {
|
||||
return [{ tag: "sub" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["sub", HTMLAttributes, 0];
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Block-level text alignment extension (`left` | `center` | `right` | `justify`)
|
||||
* applied to paragraphs, headings, and blockquotes.
|
||||
*/
|
||||
export const TextAlignExtension = Extension.create({
|
||||
name: "textAlign",
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: ["heading", "paragraph", "blockquote"],
|
||||
attributes: {
|
||||
textAlign: {
|
||||
default: "left",
|
||||
parseHTML: (element: HTMLElement) => element.style.textAlign || "left",
|
||||
renderHTML: (attributes: { textAlign?: string }) => {
|
||||
if (!attributes.textAlign || attributes.textAlign === "left") {
|
||||
return {};
|
||||
}
|
||||
return { style: `text-align: ${attributes.textAlign}` };
|
||||
},
|
||||
},
|
||||
lineHeight: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.style.lineHeight || null,
|
||||
renderHTML: (attributes: { lineHeight?: string | null }) => {
|
||||
if (!attributes.lineHeight) return {};
|
||||
return { style: `line-height: ${attributes.lineHeight}` };
|
||||
},
|
||||
},
|
||||
textIndent: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.style.textIndent || null,
|
||||
renderHTML: (attributes: { textIndent?: string | null }) => {
|
||||
if (!attributes.textIndent) return {};
|
||||
return { style: `text-indent: ${attributes.textIndent}` };
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The full extension list, registered once. Order matters: base kits first,
|
||||
* then text styling, then PubScript nodes.
|
||||
@@ -44,8 +135,28 @@ import {
|
||||
export const pubScriptExtensions = [
|
||||
StarterKit,
|
||||
TextStyle.extend({
|
||||
addAttributes: colorAttribute,
|
||||
addAttributes() {
|
||||
return {
|
||||
...colorAttribute(),
|
||||
fontFamily: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.style.fontFamily || null,
|
||||
renderHTML: (attributes: { fontFamily?: string | null }) =>
|
||||
attributes.fontFamily ? { style: `font-family: ${attributes.fontFamily}` } : {},
|
||||
},
|
||||
fontSize: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.style.fontSize || null,
|
||||
renderHTML: (attributes: { fontSize?: string | null }) =>
|
||||
attributes.fontSize ? { style: `font-size: ${attributes.fontSize}` } : {},
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
HighlightMark,
|
||||
SuperscriptMark,
|
||||
SubscriptMark,
|
||||
TextAlignExtension,
|
||||
SceneBreakNode,
|
||||
FootnoteInline,
|
||||
FootnoteArea,
|
||||
|
||||
@@ -38,11 +38,31 @@ function buildRuns(text: string, marks: Mark[]): Run[] {
|
||||
return runs;
|
||||
}
|
||||
|
||||
function resolvePdfFont(fontFamily: string): "times" | "helvetica" | "courier" {
|
||||
const lower = fontFamily.toLowerCase();
|
||||
if (lower.includes("mono") || lower.includes("courier") || lower.includes("consolas")) {
|
||||
return "courier";
|
||||
}
|
||||
if (
|
||||
lower.includes("serif") ||
|
||||
lower.includes("garamond") ||
|
||||
lower.includes("georgia") ||
|
||||
lower.includes("iowan") ||
|
||||
lower.includes("times")
|
||||
) {
|
||||
return "times";
|
||||
}
|
||||
return "helvetica";
|
||||
}
|
||||
|
||||
function setRunFont(doc: jsPDF, run: Run, theme: Theme): void {
|
||||
const hasCode = run.marks.some((m) => m.type === "code");
|
||||
const hasBold = run.marks.some((m) => m.type === "bold");
|
||||
const hasItalic = run.marks.some((m) => m.type === "italic");
|
||||
doc.setFont(hasCode ? "courier" : theme.fontFamily.split(",")[0]!.replace(/"/g, "").trim() || "helvetica", hasBold ? "bold" : hasItalic ? "italic" : "normal");
|
||||
doc.setFont(
|
||||
hasCode ? "courier" : resolvePdfFont(theme.fontFamily),
|
||||
hasBold ? "bold" : hasItalic ? "italic" : "normal",
|
||||
);
|
||||
}
|
||||
|
||||
function pageGeometry() {
|
||||
@@ -55,7 +75,7 @@ function pageGeometry() {
|
||||
}
|
||||
|
||||
function newPage(doc: jsPDF, theme: Theme): void {
|
||||
doc.setFont(theme.fontFamily.split(",")[0]!.replace(/"/g, "").trim() || "helvetica", "normal");
|
||||
doc.setFont(resolvePdfFont(theme.fontFamily), "normal");
|
||||
doc.setTextColor(hexToRgb(theme.colors.text));
|
||||
doc.setFillColor(hexToRgb(theme.colors.background));
|
||||
doc.rect(0, 0, doc.internal.pageSize.getWidth(), doc.internal.pageSize.getHeight(), "F");
|
||||
@@ -111,7 +131,7 @@ function renderBlocks(
|
||||
const level = Math.max(1, Math.min(6, block.level ?? 1));
|
||||
const size = level === 1 ? 24 : level === 2 ? 18 : 14;
|
||||
doc.setFontSize(size);
|
||||
doc.setFont(theme.fontFamily.split(",")[0]!.replace(/"/g, "").trim() || "helvetica", "bold");
|
||||
doc.setFont(resolvePdfFont(theme.fontFamily), "bold");
|
||||
doc.setTextColor(hexToRgb(theme.colors.heading));
|
||||
y += size * 0.9;
|
||||
y = renderParagraph(doc, block.text ?? "", block.marks ?? [], margin, y, maxWidth, lineHeight * 1.2, theme);
|
||||
@@ -120,7 +140,7 @@ function renderBlocks(
|
||||
}
|
||||
case "paragraph": {
|
||||
doc.setFontSize(12);
|
||||
doc.setFont(theme.fontFamily.split(",")[0]!.replace(/"/g, "").trim() || "helvetica", "normal");
|
||||
doc.setFont(resolvePdfFont(theme.fontFamily), "normal");
|
||||
doc.setTextColor(hexToRgb(theme.colors.text));
|
||||
y += lineHeight;
|
||||
y = renderParagraph(doc, block.text ?? "", block.marks ?? [], margin, y, maxWidth, lineHeight, theme);
|
||||
|
||||
+630
-166
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Font families per theme. See PLAN.md §4.7.
|
||||
* System fonts only — no network fetches.
|
||||
* UI chrome is locked to System Sans across all themes so typography changes
|
||||
* only affect the writing editor surface.
|
||||
*/
|
||||
|
||||
export interface FontSet {
|
||||
/** UI chrome font. */
|
||||
/** UI chrome font (locked to System Sans). */
|
||||
ui: string;
|
||||
/** Body / prose font. */
|
||||
body: string;
|
||||
@@ -12,24 +13,27 @@ export interface FontSet {
|
||||
mono: string;
|
||||
}
|
||||
|
||||
export const SYSTEM_UI_FONT =
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
|
||||
|
||||
export const FONTS: Record<string, FontSet> = {
|
||||
ink: {
|
||||
ui: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||
ui: SYSTEM_UI_FONT,
|
||||
body: "Georgia, 'Times New Roman', serif",
|
||||
mono: "'SFMono-Regular', ui-monospace, 'Cascadia Code', Consolas, monospace",
|
||||
},
|
||||
parchment: {
|
||||
ui: "Georgia, 'Times New Roman', serif",
|
||||
ui: SYSTEM_UI_FONT,
|
||||
body: "Georgia, 'Times New Roman', serif",
|
||||
mono: "'SFMono-Regular', ui-monospace, Consolas, monospace",
|
||||
},
|
||||
nocturne: {
|
||||
ui: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||
ui: SYSTEM_UI_FONT,
|
||||
body: "Georgia, 'Times New Roman', serif",
|
||||
mono: "'SFMono-Regular', ui-monospace, 'Cascadia Code', Consolas, monospace",
|
||||
},
|
||||
terminal: {
|
||||
ui: "'SFMono-Regular', ui-monospace, 'Cascadia Code', Consolas, monospace",
|
||||
ui: SYSTEM_UI_FONT,
|
||||
body: "'SFMono-Regular', ui-monospace, Consolas, monospace",
|
||||
mono: "'SFMono-Regular', ui-monospace, Consolas, monospace",
|
||||
},
|
||||
|
||||
@@ -166,3 +166,15 @@ export function applyFontSettings(s: Partial<FontSettings>): void {
|
||||
if (s.measure != null) root.style.setProperty("--ps-font-measure", `${s.measure}px`);
|
||||
if (s.family != null && s.family !== "") root.style.setProperty("--ps-font-body", s.family);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply UI density ("compact" | "comfortable") by setting `data-density` on
|
||||
* `:root` and updating `--ps-density` and `--ps-topbar-h`.
|
||||
*/
|
||||
export function applyDensity(density: "compact" | "comfortable"): void {
|
||||
const root = typeof document !== "undefined" ? document.documentElement : undefined;
|
||||
if (!root) return;
|
||||
root.dataset.density = density;
|
||||
root.style.setProperty("--ps-density", density === "compact" ? "8px" : "14px");
|
||||
root.style.setProperty("--ps-topbar-h", density === "compact" ? "40px" : "48px");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user