From b1008b6b01a00aa0086aaa5174637850e053afc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=AAsufian=20nasser=E2=80=AC=E2=80=8F?= <166199800+sufyanaser@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:34:37 +0300 Subject: [PATCH 1/2] stabilize live heading collapse --- src/renderer/components/NoteEditorArea.tsx | 179 ++---------- src/renderer/editorInteractionStability.ts | 87 ------ .../extensions/CollapsibleSections.ts | 262 ++++++++++++++++++ tests/editor-interaction-stability.test.mjs | 9 +- tests/editor-productivity-features.test.mjs | 35 ++- 5 files changed, 314 insertions(+), 258 deletions(-) create mode 100644 src/renderer/extensions/CollapsibleSections.ts diff --git a/src/renderer/components/NoteEditorArea.tsx b/src/renderer/components/NoteEditorArea.tsx index 5ff89c2..60c08f9 100644 --- a/src/renderer/components/NoteEditorArea.tsx +++ b/src/renderer/components/NoteEditorArea.tsx @@ -24,6 +24,12 @@ import { customColumnResizing, normalizeSelectedTableColumnWidths } from "../ext import { tableEditing } from "@tiptap/pm/tables"; import { TextDirection } from "../extensions/TextDirection"; import { Indent } from "../extensions/Indent"; +import { + CollapsibleSections, + getActiveCollapsibleSection, + resetCollapsibleSections, + toggleActiveCollapsibleSection, +} from "../extensions/CollapsibleSections"; import { LinkDialog } from "./LinkDialog"; import { EditorContextMenu } from "./EditorContextMenu"; import { EditorNoteHeader } from "./EditorNoteHeader"; @@ -1037,8 +1043,6 @@ export function NoteEditorArea({ }: NoteEditorAreaProps): JSX.Element { const hasSelectedNote = selectedNote !== null; const isLocked = selectedNote?.isLocked === true; - const collapsedHeadingKeysRef = useRef(new Set()); - const manualSectionKeysRef = useRef(new Set()); const isSettingContentRef = useRef(false); const loadedNoteIdRef = useRef(null); @@ -1053,8 +1057,7 @@ export function NoteEditorArea({ const [editorMenuPos, setEditorMenuPos] = useState<{ x: number; y: number } | null>(null); const [isTableCellSelected, setIsTableCellSelected] = useState(false); const [selectedDividerVariant, setSelectedDividerVariant] = useState(null); - const [activeCollapse, setActiveCollapse] = useState<{ key: string; collapsed: boolean } | null>(null); - const refreshCollapsibleHeadingsRef = useRef<() => void>(() => undefined); + const [activeCollapse, setActiveCollapse] = useState<{ position: number; collapsed: boolean } | null>(null); useEffect(() => { setEditorMenuPos(null); @@ -1187,6 +1190,7 @@ export function NoteEditorArea({ LineHeight, TextDirection, Indent, + CollapsibleSections, CustomTable.configure({ resizable: true, cellMinWidth: 96, lastColumnResizable: false }), TableRow, TableHeaderWithBg, @@ -1359,162 +1363,24 @@ export function NoteEditorArea({ }, [editor, isTrashView, hasSelectedNote, isLocked]); useEffect(() => { - if (!editor) return; - const root = editor.view.dom; - collapsedHeadingKeysRef.current.clear(); - manualSectionKeysRef.current.clear(); - - const getBlockAtSelection = (): HTMLElement | null => { - const { $from } = editor.state.selection; - for (let depth = $from.depth; depth > 0; depth -= 1) { - const nodeName = $from.node(depth).type.name; - if (nodeName !== "heading" && nodeName !== "paragraph") continue; - const element = editor.view.nodeDOM($from.before(depth)); - return element instanceof HTMLElement ? element : null; - } - return null; - }; - - const refresh = () => { - root.querySelectorAll("[data-nas-collapsed-hidden=\"true\"]").forEach((element) => { - element.removeAttribute("data-nas-collapsed-hidden"); - }); - - const blocks = [...root.children].filter( - (element): element is HTMLElement => element instanceof HTMLElement - ); - blocks.forEach((block, index) => { - block.removeAttribute("data-nas-collapsible"); - block.removeAttribute("data-nas-collapsed"); - if (!/^(?:P|H[1-6])$/u.test(block.tagName)) { - block.removeAttribute("data-nas-collapse-key"); - return; - } - block.dataset.nasCollapseKey = - `${block.tagName}:${index}:${(block.textContent ?? "").trim()}`; - }); - - const isVisualHeading = (block: HTMLElement): boolean => { - if (block.tagName !== "P" || !(block.textContent ?? "").trim()) return false; - const styledText = block.querySelector("span, strong, b") ?? block; - const style = getComputedStyle(styledText); - const fontSize = Number.parseFloat(style.fontSize); - const fontWeight = Number.parseInt(style.fontWeight, 10); - return fontSize >= 18 && (fontWeight >= 600 || style.fontWeight === "bold"); - }; - - const isSectionHeading = (block: HTMLElement): boolean => { - const key = block.dataset.nasCollapseKey; - return /^H[1-6]$/u.test(block.tagName) - || Boolean(key && manualSectionKeysRef.current.has(key)) - || isVisualHeading(block); - }; - - const sectionFor = (heading: HTMLElement): HTMLElement[] => { - const structuralLevel = /^H[1-6]$/u.test(heading.tagName) - ? Number(heading.tagName.slice(1)) - : null; - const section: HTMLElement[] = []; - let sibling = heading.nextElementSibling; - while (sibling instanceof HTMLElement) { - const siblingLevel = /^H[1-6]$/u.test(sibling.tagName) - ? Number(sibling.tagName.slice(1)) - : null; - const reachesNextSection = isSectionHeading(sibling) - && (structuralLevel === null - || siblingLevel === null - || siblingLevel <= structuralLevel); - if (sibling.tagName === "HR" || reachesNextSection) break; - section.push(sibling); - sibling = sibling.nextElementSibling; - } - return section; - }; - - blocks.filter(isSectionHeading).forEach((heading) => { - const key = heading.dataset.nasCollapseKey; - if (!key) return; - const section = sectionFor(heading); - if (section.length === 0) { - return; - } - heading.dataset.nasCollapsible = "true"; - const collapsed = collapsedHeadingKeysRef.current.has(key); - heading.dataset.nasCollapsed = collapsed ? "true" : "false"; - if (collapsed) { - section.forEach((element) => { - element.dataset.nasCollapsedHidden = "true"; - }); - } - }); - - const activeHeading = getBlockAtSelection(); - const activeKey = activeHeading?.dataset.nasCollapseKey; - const hasSection = activeHeading ? sectionFor(activeHeading).length > 0 : false; - setActiveCollapse( - activeKey && hasSection && (activeHeading?.textContent ?? "").trim() - ? { key: activeKey, collapsed: collapsedHeadingKeysRef.current.has(activeKey) } - : null - ); - }; - refreshCollapsibleHeadingsRef.current = refresh; - let refreshTimeout: number | null = null; - const scheduleRefresh = () => { - if (refreshTimeout !== null) { - window.clearTimeout(refreshTimeout); - } - refreshTimeout = window.setTimeout(() => { - refreshTimeout = null; - refresh(); - }, 80); - }; - - const handlePointerDown = (event: PointerEvent) => { - const target = event.target instanceof Element - ? event.target.closest("[data-nas-collapsible=\"true\"]") - : null; - if (!target || target.dataset.nasCollapsible !== "true") return; - const rectangle = target.getBoundingClientRect(); - const isRtl = getComputedStyle(target).direction === "rtl"; - const hit = isRtl - ? event.clientX >= rectangle.right - 34 - : event.clientX <= rectangle.left + 34; - if (!hit) return; - - event.preventDefault(); - const key = target.dataset.nasCollapseKey; - if (!key) return; - if (collapsedHeadingKeysRef.current.has(key)) { - collapsedHeadingKeysRef.current.delete(key); - } else { - collapsedHeadingKeysRef.current.add(key); - } - refresh(); + if (!editor) { + setActiveCollapse(null); + return; + } + const updateActiveCollapse = () => { + setActiveCollapse(getActiveCollapsibleSection(editor)); }; - - requestAnimationFrame(refresh); - root.addEventListener("pointerdown", handlePointerDown, true); - editor.on("transaction", scheduleRefresh); + updateActiveCollapse(); + editor.on("selectionUpdate", updateActiveCollapse); + editor.on("transaction", updateActiveCollapse); return () => { - refreshCollapsibleHeadingsRef.current = () => undefined; - if (refreshTimeout !== null) { - window.clearTimeout(refreshTimeout); - } - root.removeEventListener("pointerdown", handlePointerDown, true); - editor.off("transaction", scheduleRefresh); + editor.off("selectionUpdate", updateActiveCollapse); + editor.off("transaction", updateActiveCollapse); }; }, [editor, selectedNote?.id]); const toggleActiveHeadingCollapse = () => { - if (!activeCollapse) return; - manualSectionKeysRef.current.add(activeCollapse.key); - if (collapsedHeadingKeysRef.current.has(activeCollapse.key)) { - collapsedHeadingKeysRef.current.delete(activeCollapse.key); - } else { - collapsedHeadingKeysRef.current.add(activeCollapse.key); - } - refreshCollapsibleHeadingsRef.current(); - editor?.commands.focus(); + if (editor) toggleActiveCollapsibleSection(editor); }; useEffect(() => { @@ -1626,10 +1492,10 @@ export function NoteEditorArea({ }); isSettingContentRef.current = true; + resetCollapsibleSections(editor); editor.commands.setContent(targetContent); loadedNoteIdRef.current = selectedNote.id; isSettingContentRef.current = false; - requestAnimationFrame(() => refreshCollapsibleHeadingsRef.current()); nasDebugLog("[TRACE] NoteEditorArea setContent END (has note)", { reason: "setContent", @@ -1652,10 +1518,10 @@ export function NoteEditorArea({ }); isSettingContentRef.current = true; + resetCollapsibleSections(editor); editor.commands.setContent(""); loadedNoteIdRef.current = null; isSettingContentRef.current = false; - requestAnimationFrame(() => refreshCollapsibleHeadingsRef.current()); nasDebugLog("[TRACE] NoteEditorArea setContent END (no note)", { reason: "setContent", @@ -2438,6 +2304,7 @@ export function NoteEditorArea({ : (language === "ar" ? "ضع المؤشر داخل عنوان قابل للطي" : "Place the cursor in a collapsible heading") } disabled={!hasSelectedNote || !activeCollapse} + onMouseDown={(event) => event.preventDefault()} onClick={toggleActiveHeadingCollapse} type="button" > diff --git a/src/renderer/editorInteractionStability.ts b/src/renderer/editorInteractionStability.ts index 1a706fe..0f092da 100644 --- a/src/renderer/editorInteractionStability.ts +++ b/src/renderer/editorInteractionStability.ts @@ -1,82 +1,11 @@ import "./styles/editor-interaction-stability.css"; const EDITOR_SELECTOR = ".note-editor-content-wrapper .ProseMirror"; -let pendingFrame: number | null = null; function getEditorRoot(): HTMLElement | null { return document.querySelector(EDITOR_SELECTOR); } -function isVisualHeading(block: HTMLElement): boolean { - if (block.tagName !== "P" || !(block.textContent ?? "").trim()) return false; - const styledText = block.querySelector("span, strong, b") ?? block; - const style = getComputedStyle(styledText); - const fontSize = Number.parseFloat(style.fontSize); - const fontWeight = Number.parseInt(style.fontWeight, 10); - return fontSize >= 18 && (fontWeight >= 600 || style.fontWeight === "bold"); -} - -function isSectionHeading(block: HTMLElement): boolean { - return /^H[1-6]$/u.test(block.tagName) || isVisualHeading(block); -} - -function sectionElements(heading: HTMLElement): readonly HTMLElement[] { - const structuralLevel = /^H[1-6]$/u.test(heading.tagName) - ? Number(heading.tagName.slice(1)) - : null; - const section: HTMLElement[] = []; - let sibling = heading.nextElementSibling; - - while (sibling instanceof HTMLElement) { - const siblingLevel = /^H[1-6]$/u.test(sibling.tagName) - ? Number(sibling.tagName.slice(1)) - : null; - const reachesNextSection = isSectionHeading(sibling) - && (structuralLevel === null - || siblingLevel === null - || siblingLevel <= structuralLevel); - - if (sibling.tagName === "HR" || reachesNextSection) break; - section.push(sibling); - sibling = sibling.nextElementSibling; - } - - return section; -} - -function reconcileCollapsibleSections(): void { - const root = getEditorRoot(); - if (!root) return; - - const blocks = [...root.children].filter( - (element): element is HTMLElement => element instanceof HTMLElement, - ); - - blocks.forEach((block, index) => { - if (!/^(?:P|H[1-6])$/u.test(block.tagName)) return; - if (!block.dataset.nasCollapseKey) { - block.dataset.nasCollapseKey = - `${block.tagName}:${index}:${(block.textContent ?? "").trim()}`; - } - }); - - blocks.filter(isSectionHeading).forEach((heading) => { - if (sectionElements(heading).length === 0) return; - heading.dataset.nasCollapsible = "true"; - if (!heading.dataset.nasCollapsed) { - heading.dataset.nasCollapsed = "false"; - } - }); -} - -function scheduleReconcile(): void { - if (pendingFrame !== null) return; - pendingFrame = window.requestAnimationFrame(() => { - pendingFrame = null; - reconcileCollapsibleSections(); - }); -} - function preserveEditorSelection(event: MouseEvent): void { const target = event.target instanceof Element ? event.target.closest(".color-picker-trigger, .color-swatch-button") @@ -100,20 +29,4 @@ export function installEditorInteractionStability(): void { document.documentElement.dataset.nasEditorInteractionStability = "true"; document.addEventListener("mousedown", preserveEditorSelection, true); - document.addEventListener("input", scheduleReconcile, true); - document.addEventListener("focusin", scheduleReconcile, true); - document.addEventListener("mouseup", scheduleReconcile, true); - - const observer = new MutationObserver(scheduleReconcile); - observer.observe(document.body, { - childList: true, - subtree: true, - characterData: true, - attributes: true, - attributeFilter: ["class", "style", "contenteditable", "data-selected"], - }); - - scheduleReconcile(); - window.setTimeout(scheduleReconcile, 80); - window.setTimeout(scheduleReconcile, 220); } diff --git a/src/renderer/extensions/CollapsibleSections.ts b/src/renderer/extensions/CollapsibleSections.ts new file mode 100644 index 0000000..1e2a90a --- /dev/null +++ b/src/renderer/extensions/CollapsibleSections.ts @@ -0,0 +1,262 @@ +import { Extension, type Editor } from "@tiptap/core"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Plugin, PluginKey, TextSelection, type EditorState, type Transaction } from "@tiptap/pm/state"; +import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; + +interface CollapsiblePluginState { + readonly collapsedPositions: ReadonlySet; + readonly manualHeadingPositions: ReadonlySet; +} + +interface SectionBlock { + readonly node: ProseMirrorNode; + readonly position: number; +} + +interface CollapsibleSection { + readonly heading: SectionBlock; + readonly content: readonly SectionBlock[]; +} + +interface ToggleSectionMeta { + readonly position: number; + readonly manual: boolean; +} + +interface ResetSectionsMeta { + readonly reset: true; +} + +export interface ActiveCollapsibleSection { + readonly position: number; + readonly collapsed: boolean; +} + +const collapsibleSectionsKey = new PluginKey("nasCollapsibleSections"); + +function mapPositions(positions: ReadonlySet, transaction: Transaction): ReadonlySet { + const mappedPositions = new Set(); + positions.forEach((position) => { + const mapped = transaction.mapping.mapResult(position, 1); + if (!mapped.deleted) mappedPositions.add(mapped.pos); + }); + return mappedPositions; +} + +function styledCharacterRatio(node: ProseMirrorNode, predicate: (mark: ProseMirrorNode["marks"][number]) => boolean): number { + let totalCharacters = 0; + let styledCharacters = 0; + node.descendants((child) => { + if (!child.isText || !child.text) return; + const characters = child.text.trim().length; + totalCharacters += characters; + if (child.marks.some(predicate)) styledCharacters += characters; + }); + return totalCharacters === 0 ? 0 : styledCharacters / totalCharacters; +} + +function isVisualHeading(node: ProseMirrorNode): boolean { + if (node.type.name !== "paragraph" || node.textContent.trim() === "") return false; + const largeTextRatio = styledCharacterRatio(node, (mark) => { + const fontSize = Number.parseFloat(String(mark.attrs.fontSize ?? "")); + return Number.isFinite(fontSize) && fontSize >= 18; + }); + const strongTextRatio = styledCharacterRatio(node, (mark) => { + if (mark.type.name === "bold") return true; + const fontWeight = Number.parseInt(String(mark.attrs.fontWeight ?? ""), 10); + return Number.isFinite(fontWeight) && fontWeight >= 600; + }); + return largeTextRatio >= 0.8 && strongTextRatio >= 0.8; +} + +function structuralLevel(block: SectionBlock): number | null { + return block.node.type.name === "heading" ? Number(block.node.attrs.level) : null; +} + +function isSectionHeading(block: SectionBlock, manualPositions: ReadonlySet): boolean { + return block.node.type.name === "heading" + || manualPositions.has(block.position) + || isVisualHeading(block.node); +} + +function collectSections( + doc: ProseMirrorNode, + manualPositions: ReadonlySet, +): readonly CollapsibleSection[] { + const blocks: SectionBlock[] = []; + doc.forEach((node, position) => blocks.push({ node, position })); + + return blocks.flatMap((heading, headingIndex) => { + if (!isSectionHeading(heading, manualPositions)) return []; + const headingLevel = structuralLevel(heading); + const content: SectionBlock[] = []; + + for (let index = headingIndex + 1; index < blocks.length; index += 1) { + const candidate = blocks[index]; + const candidateLevel = structuralLevel(candidate); + const reachesNextSection = isSectionHeading(candidate, manualPositions) + && (headingLevel === null || candidateLevel === null || candidateLevel <= headingLevel); + if (candidate.node.type.name === "horizontalRule" || reachesNextSection) break; + content.push(candidate); + } + + return content.length > 0 ? [{ heading, content }] : []; + }); +} + +function createDecorations(doc: ProseMirrorNode, pluginState: CollapsiblePluginState): DecorationSet { + const decorations: Decoration[] = []; + collectSections(doc, pluginState.manualHeadingPositions).forEach(({ heading, content }) => { + const collapsed = pluginState.collapsedPositions.has(heading.position); + decorations.push(Decoration.node( + heading.position, + heading.position + heading.node.nodeSize, + { + "data-nas-collapse-key": `position-${heading.position}`, + "data-nas-collapsible": "true", + "data-nas-collapsed": collapsed ? "true" : "false", + }, + )); + if (collapsed) { + content.forEach((block) => { + decorations.push(Decoration.node( + block.position, + block.position + block.node.nodeSize, + { "data-nas-collapsed-hidden": "true" }, + )); + }); + } + }); + return DecorationSet.create(doc, decorations); +} + +function positionFromHeadingElement(element: HTMLElement): number | null { + const match = /^position-(\d+)$/u.exec(element.dataset.nasCollapseKey ?? ""); + return match ? Number(match[1]) : null; +} + +function restoreHeadingAnchor(view: EditorView, heading: HTMLElement, anchorTop: number): void { + const scrollContainer = view.dom.closest(".note-editor-content-wrapper"); + if (!scrollContainer || !heading.isConnected) return; + const offset = heading.getBoundingClientRect().top - anchorTop; + if (Math.abs(offset) > 0.5) scrollContainer.scrollTop += offset; +} + +function handleHeadingPointerDown(view: EditorView, event: PointerEvent): boolean { + const heading = event.target instanceof Element + ? event.target.closest("[data-nas-collapsible=\"true\"]") + : null; + if (!heading || !view.dom.contains(heading)) return false; + + const rectangle = heading.getBoundingClientRect(); + const isRtl = getComputedStyle(heading).direction === "rtl"; + const hitsToggle = isRtl + ? event.clientX >= rectangle.right - 34 + : event.clientX <= rectangle.left + 34; + if (!hitsToggle) return false; + + const position = positionFromHeadingElement(heading); + const pluginState = collapsibleSectionsKey.getState(view.state); + if (position === null || !pluginState) return false; + + event.preventDefault(); + event.stopPropagation(); + const anchorTop = rectangle.top; + const willCollapse = !pluginState.collapsedPositions.has(position); + let transaction = view.state.tr.setMeta(collapsibleSectionsKey, { + position, + manual: pluginState.manualHeadingPositions.has(position), + } satisfies ToggleSectionMeta); + if (willCollapse) { + transaction = transaction + .setSelection(TextSelection.near(view.state.doc.resolve(position + 1), 1)) + .setMeta("addToHistory", false); + } + view.dispatch(transaction); + restoreHeadingAnchor(view, heading, anchorTop); + window.requestAnimationFrame(() => restoreHeadingAnchor(view, heading, anchorTop)); + return true; +} + +export function getActiveCollapsibleSection(editor: Editor): ActiveCollapsibleSection | null { + const pluginState = collapsibleSectionsKey.getState(editor.state); + if (!pluginState) return null; + const { $from } = editor.state.selection; + for (let depth = $from.depth; depth > 0; depth -= 1) { + const node = $from.node(depth); + if (node.type.name !== "heading" && node.type.name !== "paragraph") continue; + const position = $from.before(depth); + const section = collectSections(editor.state.doc, pluginState.manualHeadingPositions) + .find((candidate) => candidate.heading.position === position); + if (!section && node.type.name !== "paragraph") return null; + const canBecomeManualHeading = node.type.name === "paragraph" + && collectSections(editor.state.doc, new Set([...pluginState.manualHeadingPositions, position])) + .some((candidate) => candidate.heading.position === position); + if (!section && !canBecomeManualHeading) return null; + return { position, collapsed: pluginState.collapsedPositions.has(position) }; + } + return null; +} + +export function toggleActiveCollapsibleSection(editor: Editor): boolean { + const activeSection = getActiveCollapsibleSection(editor); + if (!activeSection) return false; + const node = editor.state.doc.nodeAt(activeSection.position); + editor.view.dispatch(editor.state.tr.setMeta(collapsibleSectionsKey, { + position: activeSection.position, + manual: node?.type.name === "paragraph", + } satisfies ToggleSectionMeta)); + return true; +} + +export function resetCollapsibleSections(editor: Editor): void { + editor.view.dispatch(editor.state.tr.setMeta(collapsibleSectionsKey, { + reset: true, + } satisfies ResetSectionsMeta)); +} + +export const CollapsibleSections = Extension.create({ + name: "nasCollapsibleSections", + + addProseMirrorPlugins() { + return [new Plugin({ + key: collapsibleSectionsKey, + state: { + init: () => ({ + collapsedPositions: new Set(), + manualHeadingPositions: new Set(), + }), + apply: (transaction, previous) => { + const meta = transaction.getMeta(collapsibleSectionsKey) as + | ToggleSectionMeta + | ResetSectionsMeta + | undefined; + if (meta && "reset" in meta) { + return { + collapsedPositions: new Set(), + manualHeadingPositions: new Set(), + }; + } + const collapsedPositions = new Set(mapPositions(previous.collapsedPositions, transaction)); + const manualHeadingPositions = new Set(mapPositions(previous.manualHeadingPositions, transaction)); + const toggle = meta as ToggleSectionMeta | undefined; + if (toggle) { + if (toggle.manual) manualHeadingPositions.add(toggle.position); + if (collapsedPositions.has(toggle.position)) collapsedPositions.delete(toggle.position); + else collapsedPositions.add(toggle.position); + } + return { collapsedPositions, manualHeadingPositions }; + }, + }, + props: { + decorations: (state: EditorState) => { + const pluginState = collapsibleSectionsKey.getState(state); + return pluginState ? createDecorations(state.doc, pluginState) : null; + }, + handleDOMEvents: { + pointerdown: (view, event) => handleHeadingPointerDown(view, event), + }, + }, + })]; + }, +}); diff --git a/tests/editor-interaction-stability.test.mjs b/tests/editor-interaction-stability.test.mjs index b6926e1..fe1b769 100644 --- a/tests/editor-interaction-stability.test.mjs +++ b/tests/editor-interaction-stability.test.mjs @@ -16,13 +16,12 @@ test("color palette keeps the ProseMirror selection before React swatch clicks", assert.match(stability, /root\.contains\(range\.commonAncestorContainer\)/); }); -test("collapse fallback refreshes headings independent of edit lock state", async () => { +test("stability layer does not mutate ProseMirror-owned collapse DOM", async () => { const stability = await source("src/renderer/editorInteractionStability.ts"); - assert.match(stability, /MutationObserver\(scheduleReconcile\)/); - assert.match(stability, /attributeFilter: \["class", "style", "contenteditable", "data-selected"\]/); - assert.match(stability, /sibling\.tagName === "HR"/); - assert.match(stability, /heading\.dataset\.nasCollapsible = "true"/); + assert.doesNotMatch(stability, /MutationObserver/); + assert.doesNotMatch(stability, /nasCollapsible/); + assert.doesNotMatch(stability, /nasCollapsed/); }); test("palette layout exposes sixteen colors after a separate reset control and maps contrast", async () => { diff --git a/tests/editor-productivity-features.test.mjs b/tests/editor-productivity-features.test.mjs index 1cc0e60..e80ef98 100644 --- a/tests/editor-productivity-features.test.mjs +++ b/tests/editor-productivity-features.test.mjs @@ -23,23 +23,38 @@ test("edit lock is persisted in SQLite and enforced by the data layer", async () test("collapsible headings respect divider and heading boundaries", async () => { const editor = await source("src/renderer/components/NoteEditorArea.tsx"); + const collapse = await source("src/renderer/extensions/CollapsibleSections.ts"); - assert.match(editor, /sibling\.tagName === "HR"/); - assert.match(editor, /siblingLevel <= structuralLevel/); - assert.match(editor, /data-nas-collapsed-hidden/); - assert.match(editor, /collapsedHeadingKeysRef/); - assert.match(editor, /requestAnimationFrame\(\(\) => refreshCollapsibleHeadingsRef\.current\(\)\)/); + assert.match(collapse, /candidate\.node\.type\.name === "horizontalRule"/); + assert.match(collapse, /candidateLevel <= headingLevel/); + assert.match(collapse, /"data-nas-collapsed-hidden": "true"/); + assert.match(collapse, /collapsedPositions/); + assert.match(collapse, /Decoration\.node/); + assert.match(editor, /resetCollapsibleSections\(editor\)/); assert.match(editor, /nas-collapse-toggle/); }); -test("visually formatted paragraphs can become collapsible sections", async () => { +test("collapsible headings remain stable while the editor is unlocked", async () => { const editor = await source("src/renderer/components/NoteEditorArea.tsx"); + const collapse = await source("src/renderer/extensions/CollapsibleSections.ts"); + + assert.match(editor, /CollapsibleSections/); + assert.doesNotMatch(editor, /dataset\.nasCollapsible\s*=/); + assert.match(collapse, /transaction\.mapping\.mapResult\(position, 1\)/); + assert.match(collapse, /TextSelection\.near\(view\.state\.doc\.resolve\(position \+ 1\), 1\)/); + assert.match(collapse, /event\.stopPropagation\(\)/); + assert.match(collapse, /handleDOMEvents/); + assert.match(collapse, /window\.requestAnimationFrame\(\(\) => restoreHeadingAnchor/); +}); + +test("visually formatted paragraphs can become collapsible sections", async () => { + const collapse = await source("src/renderer/extensions/CollapsibleSections.ts"); const styles = await source("src/renderer/styles/editor-productivity.css"); - assert.match(editor, /nodeName !== "heading" && nodeName !== "paragraph"/); - assert.match(editor, /fontSize >= 18/); - assert.match(editor, /manualSectionKeysRef\.current\.add\(activeCollapse\.key\)/); - assert.match(editor, /closest\("\[data-nas-collapsible=\\"true\\"\]"\)/); + assert.match(collapse, /largeTextRatio >= 0\.8 && strongTextRatio >= 0\.8/); + assert.match(collapse, /manualHeadingPositions/); + assert.match(collapse, /manual: node\?\.type\.name === "paragraph"/); + assert.match(collapse, /closest\("\[data-nas-collapsible=\\"true\\"\]"\)/); assert.match(styles, /\[data-nas-collapsible="true"\]\s*\{\s*position: relative;/); }); From fdec7fcef484a361d418c2dbab04c19975d93b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=AAsufian=20nasser=E2=80=AC=E2=80=8F?= <166199800+sufyanaser@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:01:23 +0300 Subject: [PATCH 2/2] enable NASbook V07 automatic updates --- .github/workflows/github-release.yml | 30 ++++-- .github/workflows/windows-release.yml | 17 +++- design-qa.md | 48 ++++++++++ electron/main/index.ts | 5 +- electron/main/updateService.ts | 75 +++++++++++++++ package-lock.json | 95 +++++++++++++++++-- package.json | 17 +++- .../styles/editor-interaction-stability.css | 15 --- src/renderer/styles/editor-productivity.css | 55 +++++++---- tests/auto-update.test.mjs | 46 +++++++++ tests/editor-productivity-features.test.mjs | 19 ++-- 11 files changed, 355 insertions(+), 67 deletions(-) create mode 100644 design-qa.md create mode 100644 electron/main/updateService.ts create mode 100644 tests/auto-update.test.mjs diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml index abd142c..5466441 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -93,10 +93,18 @@ jobs: shell: pwsh run: | $label = "${{ steps.meta.outputs.label }}" - $installer = Get-ChildItem -Path release -Filter "NASbook Setup $label.exe" -File -ErrorAction Stop + $installer = Get-ChildItem -Path release -Filter "NASbook-Setup-$label.exe" -File -ErrorAction Stop if ($installer.Count -ne 1) { throw "Expected exactly one NASbook $label installer." } "path=$($installer.FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + $metadata = Get-ChildItem -Path release -Filter "latest.yml" -File -ErrorAction Stop + if ($metadata.Count -ne 1) { throw "Expected exactly one latest.yml update manifest." } + "metadata=$($metadata.FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + $blockmap = Get-ChildItem -Path release -Filter "NASbook-Setup-$label.exe.blockmap" -File -ErrorAction Stop + if ($blockmap.Count -ne 1) { throw "Expected exactly one NSIS blockmap." } + "blockmap=$($blockmap.FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + - name: Write release notes if: steps.release-check.outputs.publish == 'true' shell: pwsh @@ -104,12 +112,13 @@ jobs: @" ## NASbook ${{ steps.meta.outputs.label }} - - Adds fast global search across visible note titles and content, including Arabic text. - - Guarantees queued autosaves finish before note navigation or application close. - - Separates editor HTML from Markdown through a transactional SQLite migration. - - Adds visible recovery for CRUD failures and renderer crashes. - - Reduces the default toolbar to core writing controls while preserving advanced customization. - - Upgrades Electron and resolves all reported npm audit vulnerabilities. + - Fixes section collapse while a note is unlocked and prevents viewport jumping. + - Adds automatic background update checks for installed Windows copies. + - Downloads future releases automatically and installs them on a normal safe exit. + - Preserves the existing application identity and local notes database. + + V07 is the first update-enabled release. Install V07 once over V06; future + releases will then arrive through the application automatically. "@ | Set-Content release-notes.md -Encoding utf8 - name: Publish GitHub Release @@ -120,6 +129,8 @@ jobs: run: | gh release create "${{ steps.meta.outputs.tag }}" ` "${{ steps.installer.outputs.path }}" ` + "${{ steps.installer.outputs.metadata }}" ` + "${{ steps.installer.outputs.blockmap }}" ` --title "NASbook ${{ steps.meta.outputs.label }}" ` --notes-file release-notes.md ` --target "${{ github.sha }}" @@ -129,6 +140,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: NASbook-Setup-${{ steps.meta.outputs.label }}-Windows - path: release/NASbook Setup ${{ steps.meta.outputs.label }}.exe + path: | + release/NASbook-Setup-${{ steps.meta.outputs.label }}.exe + release/NASbook-Setup-${{ steps.meta.outputs.label }}.exe.blockmap + release/latest.yml if-no-files-found: error retention-days: 30 diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index a3ae399..00cb8da 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -75,17 +75,30 @@ jobs: shell: pwsh run: | $label = "${{ steps.release-label.outputs.label }}" - $installer = Get-ChildItem -Path release -Filter "NASbook Setup $label.exe" -File -ErrorAction Stop + $installer = Get-ChildItem -Path release -Filter "NASbook-Setup-$label.exe" -File -ErrorAction Stop if ($installer.Count -ne 1) { throw "Expected exactly one NASbook $label installer." } Write-Host "Installer: $($installer.FullName)" Write-Host "Size: $([math]::Round($installer.Length / 1MB, 2)) MB" + $metadata = Get-ChildItem -Path release -Filter "latest.yml" -File -ErrorAction Stop + if ($metadata.Count -ne 1) { + throw "Expected exactly one latest.yml update manifest." + } + + $blockmap = Get-ChildItem -Path release -Filter "NASbook-Setup-$label.exe.blockmap" -File -ErrorAction Stop + if ($blockmap.Count -ne 1) { + throw "Expected exactly one NSIS blockmap." + } + - name: Upload installer artifact uses: actions/upload-artifact@v4 with: name: NASbook-Setup-${{ steps.release-label.outputs.label }}-Windows - path: release/NASbook Setup ${{ steps.release-label.outputs.label }}.exe + path: | + release/NASbook-Setup-${{ steps.release-label.outputs.label }}.exe + release/NASbook-Setup-${{ steps.release-label.outputs.label }}.exe.blockmap + release/latest.yml if-no-files-found: error retention-days: 30 diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..89ec32a --- /dev/null +++ b/design-qa.md @@ -0,0 +1,48 @@ +# V07 Collapse Chevron Design QA + +- Source visual truth: `C:\Users\sufia\AppData\Local\Temp\codex-clipboard-86157121-96a8-47c5-ba3d-c87ad64345ec.png` +- Open implementation: `C:\Users\sufia\AppData\Local\Temp\nasbook-v07-collapse-open.png` +- Closed implementation: `C:\Users\sufia\AppData\Local\Temp\nasbook-v07-collapse-closed.png` +- Combined comparison: `C:\Users\sufia\AppData\Local\Temp\nasbook-v07-collapse-comparison.png` +- Viewport and CSS size: 1320 x 860 +- Source pixels: 1320 x 860 +- Implementation pixels: 1320 x 860 +- Device scale factor: 1; no density normalization required +- State: light theme, RTL note, unlocked editor, same note and scroll anchor + +## Findings + +No actionable P0, P1, or P2 differences remain in the requested control. + +- Fonts and typography: unchanged from the source; heading weight, wrapping, and line height remain intact. +- Spacing and layout rhythm: the control receives a deliberate 38 px inline slot; document width and vertical rhythm remain unchanged. +- Colors and visual tokens: the chevron and its subtle surface use the existing application accent token with improved contrast. +- Image and icon quality: the existing vector-like CSS chevron remains sharp at 1x; no branding or raster assets changed. +- Copy and content: unchanged. +- Accessibility and affordance: the 10 x 10 px stroke sits in a persistent 30 x 30 px visual target, with a stronger hover state. +- Interaction: open points down; closed RTL points left toward the text; hidden content is restored on expansion. +- Stability: heading top remained exactly 319.5 px before and after collapse. + +## Full-view Comparison Evidence + +The 3960 x 860 combined image places source, open implementation, and closed implementation in one comparison. The application shell, toolbar, editor measure, and note content remain visually unchanged outside the requested chevron treatment. + +## Focused-region Evidence + +No additional crop was required because each 1320 x 860 source capture preserves the chevron at original 1:1 density and the combined comparison makes both states readable. + +## Comparison History + +1. Source issue: the small unframed chevron had weak affordance and ambiguous state direction. +2. Fix: consolidated conflicting style rules, increased chevron size and contrast, added a subtle persistent surface, and split closed direction using inherited RTL/LTR writing direction. +3. Post-fix evidence: open transform is 45 degrees with visible content; closed RTL transform is 135 degrees with the next section node hidden; the heading anchor remains fixed. + +## Implementation Checklist + +- [x] Open state points down. +- [x] Closed state points toward text in RTL and LTR. +- [x] Chevron is prominent without changing branding. +- [x] Collapse does not move the heading anchor. +- [x] Automated tests, production build, and packaged runtime pass. + +final result: passed diff --git a/electron/main/index.ts b/electron/main/index.ts index dde3ed5..2d5bf33 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -12,6 +12,7 @@ import { createGoogleAuthService } from "./googleAuthService"; import { createGoogleDriveBackupService } from "./googleDriveBackupService"; import { createGmailBackupService } from "./gmailBackupService"; import { isSafeExternalUrl } from "../../src/shared/externalUrl"; +import { disposeUpdateService, initializeUpdateService } from "./updateService"; const gotTheLock = app.requestSingleInstanceLock(); @@ -172,7 +173,7 @@ if (!gotTheLock) { registerIpcHandlers({ appName: app.getName(), - appVersion: "V06", + appVersion: "V07", database: notesbookDatabase, settingsStore, backupService, @@ -182,6 +183,7 @@ if (!gotTheLock) { }); createMainWindow(); + initializeUpdateService(); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { @@ -197,6 +199,7 @@ if (!gotTheLock) { }); app.on("before-quit", () => { + disposeUpdateService(); notesbookDatabase?.close(); notesbookDatabase = null; }); diff --git a/electron/main/updateService.ts b/electron/main/updateService.ts new file mode 100644 index 0000000..23a2b69 --- /dev/null +++ b/electron/main/updateService.ts @@ -0,0 +1,75 @@ +import { app, Notification } from "electron"; +import * as electronUpdater from "electron-updater"; + +const { autoUpdater } = electronUpdater; +const INITIAL_CHECK_DELAY_MS = 10_000; +const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; + +let initialized = false; +let checkInProgress = false; +let initialCheckTimer: NodeJS.Timeout | null = null; +let periodicCheckTimer: NodeJS.Timeout | null = null; + +async function checkForUpdates(): Promise { + if (checkInProgress) return; + + checkInProgress = true; + try { + await autoUpdater.checkForUpdates(); + } catch (error) { + console.error("Automatic update check failed:", error); + } finally { + checkInProgress = false; + } +} + +export function initializeUpdateService(): void { + if (initialized || !app.isPackaged || process.platform !== "win32") return; + initialized = true; + + autoUpdater.autoDownload = true; + autoUpdater.autoInstallOnAppQuit = true; + autoUpdater.allowPrerelease = false; + autoUpdater.logger = console; + + autoUpdater.on("checking-for-update", () => { + console.info("Checking for NASbook updates."); + }); + autoUpdater.on("update-available", (info) => { + console.info(`NASbook update ${info.version} is available; download started.`); + }); + autoUpdater.on("update-not-available", (info) => { + console.info(`NASbook ${info.version} is up to date.`); + }); + autoUpdater.on("update-downloaded", (info) => { + console.info(`NASbook update ${info.version} is ready and will install on exit.`); + if (Notification.isSupported()) { + new Notification({ + title: "NASbook", + body: "تم تنزيل تحديث جديد وسيتم تثبيته عند إغلاق البرنامج.", + silent: true, + }).show(); + } + }); + autoUpdater.on("error", (error) => { + console.error("NASbook updater error:", error); + }); + + initialCheckTimer = setTimeout(() => { + initialCheckTimer = null; + void checkForUpdates(); + }, INITIAL_CHECK_DELAY_MS); + initialCheckTimer.unref(); + + periodicCheckTimer = setInterval(() => { + void checkForUpdates(); + }, UPDATE_CHECK_INTERVAL_MS); + periodicCheckTimer.unref(); +} + +export function disposeUpdateService(): void { + if (initialCheckTimer) clearTimeout(initialCheckTimer); + if (periodicCheckTimer) clearInterval(periodicCheckTimer); + initialCheckTimer = null; + periodicCheckTimer = null; +} diff --git a/package-lock.json b/package-lock.json index 76b24b6..b0b2db5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nas-notesbook", - "version": "6.0.0", + "version": "7.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nas-notesbook", - "version": "6.0.0", + "version": "7.0.0", "hasInstallScript": true, "dependencies": { "@tiptap/extension-color": "^3.27.0", @@ -22,6 +22,7 @@ "@tiptap/react": "^3.27.0", "@tiptap/starter-kit": "^3.27.0", "better-sqlite3": "^12.11.1", + "electron-updater": "^6.8.9", "marked": "^18.0.5", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -3514,7 +3515,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/asn1js": { @@ -3842,7 +3842,6 @@ "version": "9.7.0", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -4217,7 +4216,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4687,6 +4685,69 @@ "dev": true, "license": "ISC" }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/electron-winstaller": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", @@ -5578,7 +5639,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -5947,7 +6007,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", - "dev": true, "funding": [ { "type": "github", @@ -6045,7 +6104,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/levn": { @@ -6111,6 +6169,19 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -6325,7 +6396,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -7662,7 +7732,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -8184,6 +8253,12 @@ "semver": "bin/semver" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", diff --git a/package.json b/package.json index 7ab0f5d..1543539 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nas-notesbook", - "version": "6.0.0", - "releaseLabel": "V06", + "version": "7.0.0", + "releaseLabel": "V07", "private": true, "description": "RTL-first local desktop notebook for NASbook.", "main": "dist/electron/main/index.js", @@ -34,6 +34,7 @@ "@tiptap/react": "^3.27.0", "@tiptap/starter-kit": "^3.27.0", "better-sqlite3": "^12.11.1", + "electron-updater": "^6.8.9", "marked": "^18.0.5", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -63,6 +64,14 @@ "directories": { "output": "release" }, + "publish": [ + { + "provider": "github", + "owner": "sufyanaser", + "repo": "NASbook", + "releaseType": "release" + } + ], "files": [ "dist/**/*", "assets/icon.ico", @@ -86,7 +95,7 @@ "nsis" ], "icon": "assets/icon.ico", - "artifactName": "NASbook Setup V06.exe" + "artifactName": "NASbook-Setup-V07.exe" }, "nsis": { "oneClick": false, @@ -96,7 +105,7 @@ "installerIcon": "assets/icon.ico", "uninstallerIcon": "assets/icon.ico", "installerHeaderIcon": "assets/icon.ico", - "artifactName": "NASbook Setup V06.${ext}" + "artifactName": "NASbook-Setup-V07.${ext}" } }, "author": "Sufyan Nasser Ali" diff --git a/src/renderer/styles/editor-interaction-stability.css b/src/renderer/styles/editor-interaction-stability.css index 7d1bab1..ff7f606 100644 --- a/src/renderer/styles/editor-interaction-stability.css +++ b/src/renderer/styles/editor-interaction-stability.css @@ -18,21 +18,6 @@ justify-self: center; } -/* Replace the border triangle with one consistent chevron in editable and locked modes. */ -.note-editor-content-wrapper .ProseMirror [data-nas-collapsible="true"]::before { - width: 7px; - height: 7px; - border: 0; - border-inline-end: 2px solid var(--app-accent); - border-bottom: 2px solid var(--app-accent); - transform: translateY(-58%) rotate(45deg); - opacity: 0.95; -} - -.note-editor-content-wrapper .ProseMirror [data-nas-collapsible="true"][data-nas-collapsed="true"]::before { - transform: translateY(-50%) rotate(-45deg); -} - /* Dark fills: use white foreground. Manual inline text color still has priority. */ .note-editor-content-wrapper .ProseMirror :is( [style*="background-color: #71717a"], diff --git a/src/renderer/styles/editor-productivity.css b/src/renderer/styles/editor-productivity.css index 8c26ccf..88d3cc7 100644 --- a/src/renderer/styles/editor-productivity.css +++ b/src/renderer/styles/editor-productivity.css @@ -1,42 +1,57 @@ .note-editor-content-wrapper .ProseMirror [data-nas-collapsible="true"] { position: relative; - padding-inline-start: 30px; + padding-inline-start: 38px; } .note-editor-content-wrapper .ProseMirror [data-nas-collapsible="true"]::before { content: ""; position: absolute; - inset-inline-start: 8px; + z-index: 2; + inset-inline-start: 11px; top: 50%; - width: 0; - height: 0; - border-top: 5px solid transparent; - border-bottom: 5px solid transparent; - border-inline-start: 8px solid var(--app-accent); - transform: translateY(-50%) rotate(90deg); + width: 10px; + height: 10px; + border: 0; + border-right: 2.5px solid var(--app-accent); + border-bottom: 2.5px solid var(--app-accent); + transform: translateY(-62%) rotate(45deg); transform-origin: center; - transition: transform 0.14s ease, opacity 0.14s ease; - opacity: 0.9; + transition: transform 0.14s ease, filter 0.14s ease; + opacity: 1; + filter: drop-shadow(0 0 2px color-mix(in srgb, var(--app-accent) 42%, transparent)); pointer-events: none; } -.note-editor-content-wrapper .ProseMirror [data-nas-collapsible="true"][data-nas-collapsed="true"]::before { - transform: translateY(-50%) rotate(0deg); -} - -.note-editor-content-wrapper .ProseMirror [data-nas-collapsible="true"]:hover::after { +.note-editor-content-wrapper .ProseMirror [data-nas-collapsible="true"]::after { content: ""; position: absolute; - inset-inline-start: 2px; + z-index: 1; + inset-inline-start: 1px; top: 50%; - width: 26px; - height: 26px; - border-radius: 7px; - background: color-mix(in srgb, var(--app-accent) 12%, transparent); + width: 30px; + height: 30px; + border: 1px solid color-mix(in srgb, var(--app-accent) 18%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--app-accent) 7%, transparent); transform: translateY(-50%); + transition: border-color 0.14s ease, background 0.14s ease; pointer-events: none; } +.note-editor-content-wrapper .ProseMirror [data-nas-collapsible="true"]:hover::after { + border-color: color-mix(in srgb, var(--app-accent) 34%, transparent); + background: color-mix(in srgb, var(--app-accent) 15%, transparent); +} + +/* Open sections point down. Closed sections point from the control toward text. */ +.note-editor-content-wrapper .ProseMirror [data-nas-collapsible="true"]:dir(ltr)[data-nas-collapsed="true"]::before { + transform: translateY(-50%) rotate(-45deg); +} + +.note-editor-content-wrapper .ProseMirror [data-nas-collapsible="true"]:dir(rtl)[data-nas-collapsed="true"]::before { + transform: translateY(-50%) rotate(135deg); +} + .note-editor-content-wrapper .ProseMirror [data-nas-collapsed-hidden="true"] { display: none !important; } diff --git a/tests/auto-update.test.mjs b/tests/auto-update.test.mjs new file mode 100644 index 0000000..e1abe1a --- /dev/null +++ b/tests/auto-update.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; + +const root = process.cwd(); + +async function source(path) { + return readFile(join(root, path), "utf8"); +} + +test("packaged Windows builds check, download, and install updates on safe exit", async () => { + const updaterSource = await source("electron/main/updateService.ts"); + const mainSource = await source("electron/main/index.ts"); + + assert.match(updaterSource, /app\.isPackaged/); + assert.match(updaterSource, /import \* as electronUpdater from "electron-updater"/); + assert.doesNotMatch(updaterSource, /import electronUpdater from "electron-updater"/); + assert.match(updaterSource, /process\.platform !== "win32"/); + assert.match(updaterSource, /autoUpdater\.autoDownload = true/); + assert.match(updaterSource, /autoUpdater\.autoInstallOnAppQuit = true/); + assert.match(updaterSource, /autoUpdater\.checkForUpdates\(\)/); + assert.doesNotMatch(updaterSource, /quitAndInstall/); + assert.match(mainSource, /initializeUpdateService\(\)/); + assert.match(mainSource, /disposeUpdateService\(\)/); +}); + +test("release configuration publishes GitHub updater metadata with V07", async () => { + const packageJson = JSON.parse(await source("package.json")); + const workflowSource = await source(".github/workflows/github-release.yml"); + + assert.equal(packageJson.version, "7.0.0"); + assert.equal(packageJson.releaseLabel, "V07"); + assert.equal(packageJson.build.appId, "com.nasfm.notesbook"); + assert.deepEqual(packageJson.build.publish, [ + { + provider: "github", + owner: "sufyanaser", + repo: "NASbook", + releaseType: "release", + }, + ]); + assert.match(workflowSource, /latest\.yml/); + assert.match(workflowSource, /\.blockmap/); + assert.match(workflowSource, /NASbook-Setup-\$label\.exe/); +}); diff --git a/tests/editor-productivity-features.test.mjs b/tests/editor-productivity-features.test.mjs index e80ef98..ea1ffe4 100644 --- a/tests/editor-productivity-features.test.mjs +++ b/tests/editor-productivity-features.test.mjs @@ -37,6 +37,7 @@ test("collapsible headings respect divider and heading boundaries", async () => test("collapsible headings remain stable while the editor is unlocked", async () => { const editor = await source("src/renderer/components/NoteEditorArea.tsx"); const collapse = await source("src/renderer/extensions/CollapsibleSections.ts"); + const styles = await source("src/renderer/styles/editor-productivity.css"); assert.match(editor, /CollapsibleSections/); assert.doesNotMatch(editor, /dataset\.nasCollapsible\s*=/); @@ -45,6 +46,10 @@ test("collapsible headings remain stable while the editor is unlocked", async () assert.match(collapse, /event\.stopPropagation\(\)/); assert.match(collapse, /handleDOMEvents/); assert.match(collapse, /window\.requestAnimationFrame\(\(\) => restoreHeadingAnchor/); + assert.match(styles, /width:\s*10px;[\s\S]*height:\s*10px;/); + assert.match(styles, /rotate\(45deg\)/); + assert.match(styles, /:dir\(ltr\)\[data-nas-collapsed="true"\][\s\S]*rotate\(-45deg\)/); + assert.match(styles, /:dir\(rtl\)\[data-nas-collapsed="true"\][\s\S]*rotate\(135deg\)/); }); test("visually formatted paragraphs can become collapsible sections", async () => { @@ -99,15 +104,15 @@ test("editor note actions cannot inherit the hidden note-card action styles", as assert.match(styles, /\.editor-note-actions\s*\{\s*gap: 10px;/); }); -test("release V06 is consistent across app metadata and Windows installer naming", async () => { +test("release V07 is consistent across app metadata and Windows installer naming", async () => { const packageJson = JSON.parse(await source("package.json")); const main = await source("electron/main/index.ts"); const workflow = await source(".github/workflows/windows-release.yml"); - assert.equal(packageJson.version, "6.0.0"); - assert.equal(packageJson.releaseLabel, "V06"); - assert.equal(packageJson.build.win.artifactName, "NASbook Setup V06.exe"); - assert.equal(packageJson.build.nsis.artifactName, "NASbook Setup V06.${ext}"); - assert.match(main, /appVersion: "V06"/); - assert.match(workflow, /NASbook Setup \$label\.exe/); + assert.equal(packageJson.version, "7.0.0"); + assert.equal(packageJson.releaseLabel, "V07"); + assert.equal(packageJson.build.win.artifactName, "NASbook-Setup-V07.exe"); + assert.equal(packageJson.build.nsis.artifactName, "NASbook-Setup-V07.${ext}"); + assert.match(main, /appVersion: "V07"/); + assert.match(workflow, /NASbook-Setup-\$label\.exe/); });