Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,10 @@ Layout rules:
- **Motion**: Reuse the existing 150ms color and press feedback. Reduced-motion mode removes the press transform.
### NoteShare

- **Structure**: One 40px share action beside `ReadLater`; it uses the browser's native share sheet when available and copies the current note URL otherwise. If `ReadLater` is unavailable, the action keeps the same inline-end placement in its own compact action group.
- **States**: Default, native share pending, shared, copied, cancelled, clipboard failure, hover, pressed, focus-visible, and disabled. Successful actions swap the share icon to a check for 1.8 seconds; cancellation returns silently to default.
- **Accessibility**: The button and polite live region use page-localized names and outcomes. The icon is decorative, the control remains keyboard-operable, and whole-note links omit heading fragments so section sharing stays owned by heading permalinks.
- **Structure**: Two 40px actions beside `ReadLater`: share uses the browser's native share sheet when available and copies the current URL otherwise, while copy creates a portable `[title](<URL>)` Markdown link for notes and documentation. If `ReadLater` is unavailable, both actions keep the same inline-end placement in their own compact group.
- **States**: Default, native share pending, shared, URL copied, Markdown copied, cancelled, clipboard failure, hover, pressed, focus-visible, and disabled. Each successful action independently swaps its icon to a check for 1.8 seconds; cancellation returns silently to default.
- **Accessibility**: Both buttons and their polite live regions use page-localized names and outcomes. Icons are decorative, controls remain keyboard-operable, and whole-note links omit heading fragments so section sharing stays owned by heading permalinks.
- **Formatting**: Markdown titles normalize whitespace and escape square brackets and backslashes. Angle-bracket destinations preserve valid URLs that contain parentheses without changing the note URL.
- **Motion**: The beui `action-swap` blur/scale mechanism is adapted to the existing 150ms micro token, using a 3px blur and 75% scale only during the icon crossfade. Reduced-motion mode removes blur, scale, and press transforms while preserving the state change.

## 6. Motion & Interaction
Expand Down
3 changes: 3 additions & 0 deletions quartz/components/renderPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,9 @@ export function renderPage(
data-note-share-shared={noteShare.shared}
data-note-share-copied={noteShare.copied}
data-note-share-failed={noteShare.failed}
data-note-share-copy-markdown={noteShare.copyMarkdown}
data-note-share-markdown-copied={noteShare.markdownCopied}
data-note-share-markdown-failed={noteShare.markdownFailed}
>
{frame.css && <style dangerouslySetInnerHTML={{ __html: frame.css }} />}
<div id="quartz-root" class="page" data-frame={frame.name}>
Expand Down
51 changes: 50 additions & 1 deletion quartz/components/scripts/noteShare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import test, { describe } from "node:test"
import enUs from "../../i18n/locales/en-US"
import zhCn from "../../i18n/locales/zh-CN"
import zhTw from "../../i18n/locales/zh-TW"
import { noteShareScript, shareNote, type NoteSharePlatform } from "./noteShare"
import {
copyMarkdownNoteLink,
formatMarkdownNoteLink,
noteShareScript,
shareNote,
type NoteSharePlatform,
} from "./noteShare"

const note = { title: "A useful note", url: "https://garden.example/note" }

Expand Down Expand Up @@ -103,6 +109,43 @@ describe("shareNote", () => {
})
})

describe("formatMarkdownNoteLink", () => {
test("formats a portable Markdown link", () => {
assert.equal(formatMarkdownNoteLink(note), "[A useful note](<https://garden.example/note>)")
})

test("normalizes whitespace and escapes Markdown label characters", () => {
assert.equal(
formatMarkdownNoteLink({
title: " A [useful] \\ note\nfor everyone ",
url: "https://garden.example/a(b)",
}),
"[A \\[useful\\] \\\\ note for everyone](<https://garden.example/a(b)>)",
)
})
})

describe("copyMarkdownNoteLink", () => {
test("copies the formatted note link", async () => {
const copied: string[] = []

const outcome = await copyMarkdownNoteLink(async (text) => {
copied.push(text)
}, note)

assert.equal(outcome, "copied")
assert.deepEqual(copied, ["[A useful note](<https://garden.example/note>)"])
})

test("reports clipboard failures", async () => {
const outcome = await copyMarkdownNoteLink(async () => {
throw new DOMException("Blocked", "NotAllowedError")
}, note)

assert.equal(outcome, "failed")
})
})

test("note-share browser script compiles", () => {
// Given
const compile = () => new Function(noteShareScript)
Expand All @@ -115,10 +158,16 @@ test("note-share browser script handles navigation and in-place renders", () =>
assert.match(noteShareScript, /document\.addEventListener\("nav", initializeNoteShare\)/)
assert.match(noteShareScript, /document\.addEventListener\("render", initializeNoteShare\)/)
assert.match(noteShareScript, /window\.addCleanup\(cleanupNoteShare\)/)
assert.match(noteShareScript, /button\.dataset\.action = action/)
assert.match(noteShareScript, /copyMarkdownNoteLink/)
assert.match(noteShareScript, /markdownButton\.focus\(\{ preventScroll: true \}\)/)
})

test("note-share labels use the central locale catalog", () => {
assert.equal(enUs.components.noteShare.title, "Share this note")
assert.equal(enUs.components.noteShare.copyMarkdown, "Copy as Markdown")
assert.equal(zhCn.components.noteShare.copied, "笔记链接已复制")
assert.equal(zhCn.components.noteShare.markdownCopied, "Markdown 链接已复制")
assert.equal(zhTw.components.noteShare.shared, "筆記已分享")
assert.equal(zhTw.components.noteShare.markdownFailed, "瀏覽器未能複製 Markdown 連結")
})
201 changes: 152 additions & 49 deletions quartz/components/scripts/noteShare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,32 @@ export type NoteShareData = {
readonly url: string
}

export type MarkdownLinkCopyOutcome = "copied" | "failed"

export function formatMarkdownNoteLink(data: NoteShareData): string {
const title = data.title
.replace(/\s+/g, " ")
.trim()
.replace(/\\/g, "\\\\")
.replace(/\[/g, "\\[")
.replace(/\]/g, "\\]")
const url = data.url.replace(/\\/g, "%5C").replace(/>/g, "%3E")
return `[${title}](<${url}>)`
}

export async function copyMarkdownNoteLink(
copy: (text: string) => Promise<void>,
data: NoteShareData,
): Promise<MarkdownLinkCopyOutcome> {
try {
await copy(formatMarkdownNoteLink(data))
return "copied"
} catch (error) {
if (error instanceof DOMException || error instanceof TypeError) return "failed"
throw error
}
}

export async function shareNote(
platform: NoteSharePlatform,
data: NoteShareData,
Expand All @@ -35,6 +61,8 @@ export async function shareNote(

export const noteShareScript = `
const shareNote = ${shareNote.toString()}
const formatMarkdownNoteLink = ${formatMarkdownNoteLink.toString()}
const copyMarkdownNoteLink = ${copyMarkdownNoteLink.toString()}

function createNoteShareIcon(pathData, className) {
const namespace = "http://www.w3.org/2000/svg"
Expand All @@ -54,9 +82,61 @@ function getNoteShareLabels() {
noteShareShared: shared,
noteShareCopied: copied,
noteShareFailed: failed,
noteShareCopyMarkdown: copyMarkdown,
noteShareMarkdownCopied: markdownCopied,
noteShareMarkdownFailed: markdownFailed,
} = document.body.dataset
if (!title || !shared || !copied || !failed) return undefined
return { title, shared, copied, failed }
if (
!title ||
!shared ||
!copied ||
!failed ||
!copyMarkdown ||
!markdownCopied ||
!markdownFailed
) {
return undefined
}
return { title, shared, copied, failed, copyMarkdown, markdownCopied, markdownFailed }
}

function createNoteShareButton(label, action, iconPath) {
const button = document.createElement("button")
button.type = "button"
button.className = "note-share-trigger"
button.dataset.action = action
button.title = label
button.setAttribute("aria-label", label)
button.append(
createNoteShareIcon(iconPath, "note-share-icon-default"),
createNoteShareIcon("m5 12 4 4L19 6", "note-share-icon-success"),
)
return button
}

function createNoteShareFeedback(button, status, defaultLabel) {
let resetTimer = 0
const reset = () => {
window.clearTimeout(resetTimer)
delete button.dataset.state
button.title = defaultLabel
button.setAttribute("aria-label", defaultLabel)
status.textContent = ""
}
const showSuccess = (message) => {
reset()
button.dataset.state = "success"
button.title = message
button.setAttribute("aria-label", message)
status.textContent = message
resetTimer = window.setTimeout(reset, 1800)
}
const showFailure = (message) => {
reset()
status.textContent = message
}
const cleanup = () => window.clearTimeout(resetTimer)
return { reset, showSuccess, showFailure, cleanup }
}

let cleanupCurrentNoteShare = () => {}
Expand All @@ -83,83 +163,106 @@ function initializeNoteShare() {
anchor.insertAdjacentElement("afterend", root)
}

const button = document.createElement("button")
button.type = "button"
button.className = "note-share-trigger"
button.title = labels.title
button.setAttribute("aria-label", labels.title)
button.append(
createNoteShareIcon(
"M4 12v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7M12 16V3m0 0L7.5 7.5M12 3l4.5 4.5",
"note-share-icon-default",
),
createNoteShareIcon("m5 12 4 4L19 6", "note-share-icon-success"),
const shareButton = createNoteShareButton(
labels.title,
"share",
"M4 12v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7M12 16V3m0 0L7.5 7.5M12 3l4.5 4.5",
)
const status = document.createElement("span")
status.className = "note-share-status"
status.setAttribute("aria-live", "polite")
root.prepend(button)
root.append(status)

let resetTimer = 0
const resetButton = () => {
delete button.dataset.state
button.title = labels.title
button.setAttribute("aria-label", labels.title)
status.textContent = ""
}
const showSuccess = (message) => {
window.clearTimeout(resetTimer)
button.dataset.state = "success"
button.title = message
button.setAttribute("aria-label", message)
status.textContent = message
resetTimer = window.setTimeout(resetButton, 1800)
const markdownButton = createNoteShareButton(
labels.copyMarkdown,
"markdown",
"M8 8h11v11H8zM5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1",
)
const createStatus = () => {
const status = document.createElement("span")
status.className = "note-share-status"
status.setAttribute("aria-live", "polite")
return status
}
const shareStatus = createStatus()
const markdownStatus = createStatus()
const shareFeedback = createNoteShareFeedback(shareButton, shareStatus, labels.title)
const markdownFeedback = createNoteShareFeedback(
markdownButton,
markdownStatus,
labels.copyMarkdown,
)
const url = new URL(location.href)
url.hash = ""
const note = { title, url: url.href }
root.prepend(shareButton, markdownButton)
root.append(shareStatus, markdownStatus)

const executeShare = async () => {
button.disabled = true
shareFeedback.reset()
shareButton.disabled = true
const copy = (text) => navigator.clipboard.writeText(text)
const platform =
typeof navigator.share === "function"
? { share: (data) => navigator.share(data), copy }
: { copy }
const url = new URL(location.href)
url.hash = ""
let outcome
try {
outcome = await shareNote(platform, { title, url: url.href })
outcome = await shareNote(platform, note)
} catch (error) {
if (!(error instanceof Error)) throw error
outcome = "failed"
} finally {
button.disabled = false
shareButton.disabled = false
}

switch (outcome) {
case "shared":
showSuccess(labels.shared)
shareFeedback.showSuccess(labels.shared)
break
case "copied":
showSuccess(labels.copied)
shareFeedback.showSuccess(labels.copied)
break
case "cancelled":
resetButton()
shareFeedback.reset()
break
case "failed":
resetButton()
status.textContent = labels.failed
shareFeedback.showFailure(labels.failed)
break
}
}
const handleClick = () => {
const executeMarkdownCopy = async () => {
markdownFeedback.reset()
const shouldRestoreFocus = document.activeElement === markdownButton
markdownButton.disabled = true
let outcome
try {
outcome = await copyMarkdownNoteLink((text) => navigator.clipboard.writeText(text), note)
} catch (error) {
if (!(error instanceof Error)) throw error
outcome = "failed"
} finally {
markdownButton.disabled = false
if (shouldRestoreFocus && markdownButton.isConnected) {
markdownButton.focus({ preventScroll: true })
}
}

if (outcome === "copied") markdownFeedback.showSuccess(labels.markdownCopied)
else markdownFeedback.showFailure(labels.markdownFailed)
}
const handleShareClick = () => {
void executeShare()
}
button.addEventListener("click", handleClick)
const handleMarkdownClick = () => {
void executeMarkdownCopy()
}
shareButton.addEventListener("click", handleShareClick)
markdownButton.addEventListener("click", handleMarkdownClick)
cleanupCurrentNoteShare = () => {
window.clearTimeout(resetTimer)
button.removeEventListener("click", handleClick)
button.remove()
status.remove()
shareFeedback.cleanup()
markdownFeedback.cleanup()
shareButton.removeEventListener("click", handleShareClick)
markdownButton.removeEventListener("click", handleMarkdownClick)
shareButton.remove()
markdownButton.remove()
shareStatus.remove()
markdownStatus.remove()
if (ownsRoot) root.remove()
else root.classList.remove("reader-actions")
}
Expand Down
3 changes: 3 additions & 0 deletions quartz/i18n/locales/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ export interface Translation {
shared: string
copied: string
failed: string
copyMarkdown: string
markdownCopied: string
markdownFailed: string
}
explorer: {
title: string
Expand Down
3 changes: 3 additions & 0 deletions quartz/i18n/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export default {
shared: "Note shared",
copied: "Note link copied",
failed: "The browser could not share this note",
copyMarkdown: "Copy as Markdown",
markdownCopied: "Markdown link copied",
markdownFailed: "The browser could not copy the Markdown link",
},
explorer: {
title: "Explorer",
Expand Down
3 changes: 3 additions & 0 deletions quartz/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export default {
shared: "笔记已分享",
copied: "笔记链接已复制",
failed: "浏览器未能分享这篇笔记",
copyMarkdown: "复制为 Markdown 链接",
markdownCopied: "Markdown 链接已复制",
markdownFailed: "浏览器未能复制 Markdown 链接",
},
explorer: {
title: "探索",
Expand Down
3 changes: 3 additions & 0 deletions quartz/i18n/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ export default {
shared: "筆記已分享",
copied: "筆記連結已複製",
failed: "瀏覽器未能分享這篇筆記",
copyMarkdown: "複製為 Markdown 連結",
markdownCopied: "Markdown 連結已複製",
markdownFailed: "瀏覽器未能複製 Markdown 連結",
},
explorer: {
title: "探索",
Expand Down