Compare commits
12 Commits
1b6c7542d7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f89c2fa776 | ||
|
|
c21619c53a | ||
|
|
b34b5851cd | ||
|
|
9581dc88aa | ||
|
|
40b0af6b01 | ||
|
|
2592b19c8f | ||
|
|
99edff828a | ||
|
|
b720759c89 | ||
|
|
a88d0dbac7 | ||
|
|
70fbe5156f | ||
|
|
049ce01ab5 | ||
|
|
f9906cba30 |
@@ -38,6 +38,10 @@ Tailwind v4 uses the modern CSS-based config in `src/styles/app.css`:
|
||||
- DaisyUI configured via `@plugin 'daisyui' { themes: retro --default, coffee --prefersdark; }`
|
||||
- `@tailwindcss/typography` for prose styling
|
||||
|
||||
### Icons
|
||||
|
||||
Use [Tabler icons](https://tabler.io/icons) for every icon. Paste the outline SVG inline and keep `stroke="currentColor"` so it inherits color from its parent (icons in the note header render in `--color-accent`).
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```sh
|
||||
|
||||
@@ -11,6 +11,7 @@ import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
|
||||
import { useNoteView } from "@/hooks/useNoteView.hook"
|
||||
import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
|
||||
import { useResolveLiveNotes } from "@/hooks/useResolveLiveNotes.hook"
|
||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||
import { useVisitRepo } from "@/modules/history/hooks/useVisitRepo.hook"
|
||||
import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue"
|
||||
@@ -43,7 +44,12 @@ useUserSettings()
|
||||
const { visitRepo } = useVisitRepo({ user: user, repo: repo })
|
||||
const { toHTML } = markdownBuilder(repo)
|
||||
const { listenToClick } = useLinks("note-display")
|
||||
const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
|
||||
const { stackedNotes, scrollToFocusedNote, scrollToLastStackedNote } =
|
||||
useRouteQueryStackedNotes()
|
||||
|
||||
// A living link arrives with paths in `?liveNotes`; resolve them to the latest
|
||||
// shas, then focus the deepest note just like an ordinary shared link does.
|
||||
useResolveLiveNotes(() => scrollToLastStackedNote())
|
||||
|
||||
const { titles } = useNoteView()
|
||||
const { isLogged } = useGitHubLogin()
|
||||
@@ -78,7 +84,10 @@ const retryLoad = () => {
|
||||
store.setUserRepo(props.user, props.repo)
|
||||
}
|
||||
|
||||
onMounted(() => visitRepo())
|
||||
onMounted(() => {
|
||||
visitRepo()
|
||||
scrollToLastStackedNote()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
store.resetFiles()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import FontChange from "@/components/FontChange.vue"
|
||||
import HomeButton from "@/components/HomeButton.vue"
|
||||
import ShareNote from "@/components/ShareNote.vue"
|
||||
|
||||
defineProps<{ user: string; repo: string }>()
|
||||
</script>
|
||||
@@ -55,6 +56,7 @@ defineProps<{ user: string; repo: string }>()
|
||||
<path d="M5 20l6 -16l2 0l7 16" />
|
||||
</svg>
|
||||
</button>
|
||||
<share-note />
|
||||
<router-link
|
||||
class="btn btn-ghost btn-circle"
|
||||
:to="{ name: 'FluxNoteView', params: { user, repo } }"
|
||||
|
||||
197
src/components/ShareNote.vue
Normal file
197
src/components/ShareNote.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue"
|
||||
import { LocationQueryRaw, useRoute, useRouter } from "vue-router"
|
||||
|
||||
import { stackToLivePaths } from "@/modules/note/liveNotes"
|
||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||
import { confirmMessage, errorMessage } from "@/utils/notif"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useUserRepoStore()
|
||||
|
||||
const stackedShas = computed<string[]>(() => {
|
||||
const raw = route.query.stackedNotes
|
||||
if (!raw) return []
|
||||
return (Array.isArray(raw) ? raw : [raw]).filter(
|
||||
(sha): sha is string => typeof sha === "string"
|
||||
)
|
||||
})
|
||||
|
||||
const hasStack = computed(() => stackedShas.value.length > 0)
|
||||
|
||||
const absoluteUrl = (query: LocationQueryRaw): string => {
|
||||
const { href } = router.resolve({ path: route.path, query })
|
||||
return `${window.location.origin}${href}`
|
||||
}
|
||||
|
||||
// The exact snapshot: the current URL, with each note pinned to its blob sha.
|
||||
const snapshotUrl = (): string => absoluteUrl({ ...route.query })
|
||||
|
||||
// The living link: the same notes referenced by path, so the recipient always
|
||||
// lands on the latest version. See useResolveLiveNotes for the read side.
|
||||
const livingUrl = (): string => {
|
||||
const query = { ...route.query }
|
||||
delete query.stackedNotes
|
||||
return absoluteUrl({
|
||||
...query,
|
||||
liveNotes: stackToLivePaths(stackedShas.value, store.files)
|
||||
})
|
||||
}
|
||||
|
||||
const close = () =>
|
||||
(document.getElementById("share_modal") as HTMLDialogElement | null)?.close()
|
||||
|
||||
const copy = async (url: string, label: string) => {
|
||||
close()
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
confirmMessage(`🔗 ${label} link copied`)
|
||||
} catch {
|
||||
errorMessage("❌ Couldn't copy the link")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="btn btn-ghost btn-circle text-accent"
|
||||
title="Share this view"
|
||||
aria-label="Share this view"
|
||||
onclick="share_modal.showModal()"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-share"
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="6" r="3" />
|
||||
<circle cx="18" cy="18" r="3" />
|
||||
<line x1="8.7" y1="10.7" x2="15.3" y2="7.3" />
|
||||
<line x1="8.7" y1="13.3" x2="15.3" y2="16.7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<dialog id="share_modal" class="modal">
|
||||
<div class="modal-box max-w-md">
|
||||
<h3 class="text-lg font-bold">Share this view</h3>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="share-option"
|
||||
@click="copy(snapshotUrl(), 'Snapshot')"
|
||||
>
|
||||
<span class="share-option-icon">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-pin"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M15 4.5l-4 4l-4 1.5l-1.5 1.5l7 7l1.5 -1.5l1.5 -4l4 -4" />
|
||||
<path d="M9 15l-4.5 4.5" />
|
||||
<path d="M14.5 4l5.5 5.5" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="share-option-text">
|
||||
<span class="share-option-title">This exact version</span>
|
||||
<span class="share-option-desc">
|
||||
Pinned to what you see now — it never changes.
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="hasStack"
|
||||
type="button"
|
||||
class="share-option"
|
||||
@click="copy(livingUrl(), 'Always-latest')"
|
||||
>
|
||||
<span class="share-option-icon">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-repeat"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M4 12v-3a3 3 0 0 1 3 -3h13m-3 -3l3 3l-3 3" />
|
||||
<path d="M20 12v3a3 3 0 0 1 -3 3h-13m3 3l-3 -3l3 -3" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="share-option-text">
|
||||
<span class="share-option-title">Always latest</span>
|
||||
<span class="share-option-desc">
|
||||
Follows these notes, so the reader always sees the current version.
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.share-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border: 1px solid var(--color-base-300);
|
||||
border-radius: var(--radius-box, 0.5rem);
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.share-option-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.share-option-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.share-option-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.share-option-desc {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
onMounted,
|
||||
ref,
|
||||
watch
|
||||
} from "vue"
|
||||
import { computed, defineAsyncComponent, onMounted, ref, watch } from "vue"
|
||||
|
||||
import { useEditionMode } from "@/hooks/useEditionMode"
|
||||
import { useFile } from "@/hooks/useFile.hook"
|
||||
@@ -188,6 +182,19 @@ const loadNote = async () => {
|
||||
|
||||
onMounted(loadNote)
|
||||
|
||||
// A note only has a trustworthy baseline once it has finished loading. While
|
||||
// it is still loading (or after a failed load) rawContent holds the empty
|
||||
// placeholder and initialRawContent is null, so a naive
|
||||
// `rawContent !== initialRawContent` check reads as a huge edit — and saving
|
||||
// that would clobber the real file on GitHub with an empty commit. Gate every
|
||||
// "is this dirty?" decision on a loaded baseline.
|
||||
const isDirty = computed(
|
||||
() =>
|
||||
loadStatus.value === "ready" &&
|
||||
initialRawContent.value !== null &&
|
||||
rawContent.value !== initialRawContent.value
|
||||
)
|
||||
|
||||
watch(
|
||||
path,
|
||||
(p) => {
|
||||
@@ -204,8 +211,7 @@ useMarkdownPostRender(content, () => `.note-${sha.value}`, {
|
||||
macroplan: true,
|
||||
mermaid: () => rawContent.value.includes("```mermaid"),
|
||||
shikiji: () => isMarkdown.value && rawContent.value.includes("```"),
|
||||
images: () =>
|
||||
/\!\[.*?\]\(.*?\)/.test(rawContent.value) ? props.sha : null,
|
||||
images: () => (/\!\[.*?\]\(.*?\)/.test(rawContent.value) ? props.sha : null),
|
||||
triggers: [mode]
|
||||
})
|
||||
|
||||
@@ -215,6 +221,13 @@ const performSave = async (overrideSha?: string) => {
|
||||
return
|
||||
}
|
||||
|
||||
// Defence in depth: never push content we didn't successfully load, or we'd
|
||||
// overwrite the file on GitHub with the empty placeholder.
|
||||
if (loadStatus.value !== "ready" || initialRawContent.value === null) {
|
||||
console.warn("refusing to save a note that hasn't finished loading")
|
||||
return
|
||||
}
|
||||
|
||||
const editedSha = overrideSha ?? (await getEditedSha()) ?? sha.value
|
||||
const { sha: newSha, conflict } = await updateFile({
|
||||
content: rawContent.value,
|
||||
@@ -287,8 +300,7 @@ watch(mode, async (newMode) => {
|
||||
return
|
||||
}
|
||||
|
||||
const hasUserFinishedToEdit =
|
||||
newMode === "read" && rawContent.value !== initialRawContent.value
|
||||
const hasUserFinishedToEdit = newMode === "read" && isDirty.value
|
||||
|
||||
if (!hasUserFinishedToEdit) {
|
||||
return
|
||||
@@ -336,7 +348,7 @@ const onBadgeClick = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const hasUnsavedEdits = rawContent.value !== initialRawContent.value
|
||||
const hasUnsavedEdits = isDirty.value
|
||||
if (hasUnsavedEdits) {
|
||||
await handleConflict()
|
||||
return
|
||||
@@ -377,7 +389,7 @@ const onBadgeClick = async () => {
|
||||
class="action"
|
||||
/>
|
||||
<button
|
||||
v-if="isMarkdown && canPush"
|
||||
v-if="isMarkdown && canPush && loadStatus === 'ready'"
|
||||
class="action button is-text is-light"
|
||||
:class="{ 'is-link': mode === 'edit' }"
|
||||
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''"
|
||||
@@ -489,7 +501,7 @@ const onBadgeClick = async () => {
|
||||
<span>You're viewing an older shared version.</span>
|
||||
<button
|
||||
type="button"
|
||||
class="snapshot-banner-action"
|
||||
class="btn bt-sm btn-soft btn-info"
|
||||
@click="viewLatest"
|
||||
>
|
||||
View latest
|
||||
@@ -588,16 +600,6 @@ $border-color: rgba(18, 19, 58, 0.2);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.snapshot-banner-action {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.action {
|
||||
margin: 0;
|
||||
|
||||
|
||||
@@ -381,10 +381,10 @@ onMounted(() => {
|
||||
comes to rest.
|
||||
</h1>
|
||||
<p class="lede">
|
||||
Point it at any markdown — a GitHub repo of your own, or public
|
||||
notes from the open ATProto network — and Remanso turns it into a
|
||||
calm, stackable notebook with backlinks where your thinking
|
||||
finally runs clear.
|
||||
Point it at any markdown: a GitHub repo of your own, or public
|
||||
notes from the open ATProto network. Remanso turns it into a
|
||||
stackable notebook with backlinks where your thinking finally
|
||||
runs clear.
|
||||
</p>
|
||||
<div class="hero-ed-paths">
|
||||
<!-- CTA 01: GitHub repo -->
|
||||
@@ -472,7 +472,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="publicNotesLoadFailed" class="network-empty">
|
||||
The pool is quiet right now.
|
||||
Nothing has drifted in yet.
|
||||
</div>
|
||||
<router-link
|
||||
:to="{ name: 'PublicNoteListView' }"
|
||||
|
||||
@@ -194,6 +194,7 @@ export const useShikiji = (): Promise<void> => {
|
||||
"html",
|
||||
"css",
|
||||
"json",
|
||||
"rust",
|
||||
{
|
||||
...alloyGrammar,
|
||||
name: "alloy",
|
||||
|
||||
50
src/hooks/useResolveLiveNotes.hook.ts
Normal file
50
src/hooks/useResolveLiveNotes.hook.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
|
||||
import { resolveLivePathsToShas } from "@/modules/note/liveNotes"
|
||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||
|
||||
// A living link (`?liveNotes=path&liveNotes=path`) carries file paths instead of
|
||||
// pinned blob shas. On open we resolve each path to the latest sha against the
|
||||
// HEAD file list and rewrite the URL to the ordinary pinned `stackedNotes` form
|
||||
// — so the recipient lands on the current version, and from there everything
|
||||
// (freshness, editing, the snapshot banner) behaves exactly as a shared snapshot
|
||||
// would. The rewrite is a `replace`, so no history entry and no view transition.
|
||||
export const useResolveLiveNotes = (onResolved?: () => void) => {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useUserRepoStore()
|
||||
|
||||
let resolved = false
|
||||
|
||||
const tryResolve = () => {
|
||||
if (resolved) return
|
||||
|
||||
const raw = route.query.liveNotes
|
||||
if (!raw) {
|
||||
resolved = true
|
||||
return
|
||||
}
|
||||
|
||||
// Wait for the HEAD file list before resolving paths to shas.
|
||||
if (store.files.length === 0) return
|
||||
resolved = true
|
||||
|
||||
const entries = (Array.isArray(raw) ? raw : [raw]).filter(
|
||||
(entry): entry is string => typeof entry === "string"
|
||||
)
|
||||
const shas = resolveLivePathsToShas(entries, store.files)
|
||||
|
||||
const query = { ...route.query }
|
||||
delete query.liveNotes
|
||||
if (shas.length) {
|
||||
query.stackedNotes = shas
|
||||
} else {
|
||||
delete query.stackedNotes
|
||||
}
|
||||
|
||||
router.replace({ path: route.path, query }).then(() => onResolved?.())
|
||||
}
|
||||
|
||||
watch(() => store.files.length, tryResolve, { immediate: true })
|
||||
}
|
||||
@@ -111,6 +111,16 @@ export const useRouteQueryStackedNotes = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Focus the deepest note when opening a shared link with stacked notes,
|
||||
// instead of landing on the readme and forcing a manual scroll (especially
|
||||
// long on mobile where notes stack vertically).
|
||||
const scrollToLastStackedNote = () => {
|
||||
const lastNote = stackedNotes.value.at(-1)
|
||||
if (lastNote) {
|
||||
scrollToFocusedNote({ noteId: lastNote })
|
||||
}
|
||||
}
|
||||
|
||||
const addStackedNote = (
|
||||
currentSha: string,
|
||||
sha: string,
|
||||
@@ -168,6 +178,7 @@ export const useRouteQueryStackedNotes = () => {
|
||||
stackedNotes: readonly(stackedNotes),
|
||||
addStackedNote,
|
||||
replaceStackedNote,
|
||||
scrollToFocusedNote
|
||||
scrollToFocusedNote,
|
||||
scrollToLastStackedNote
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +232,10 @@ const bandStyle = computed(() => ({
|
||||
display: grid;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
/* Never let a too-tall container (e.g. the mobile 100svh note height that
|
||||
cascades in) stretch the auto row tracks — pack rows to their content
|
||||
height at the top. */
|
||||
align-content: start;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
|
||||
62
src/modules/note/liveNotes.spec.ts
Normal file
62
src/modules/note/liveNotes.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { resolveLivePathsToShas, stackToLivePaths } from "./liveNotes"
|
||||
|
||||
const files = [
|
||||
{ path: "README.md", sha: "a".repeat(40) },
|
||||
{ path: "notes/one.md", sha: "b".repeat(40) },
|
||||
{ path: "notes/two.md", sha: "c".repeat(40) }
|
||||
]
|
||||
|
||||
describe("stackToLivePaths", () => {
|
||||
it("maps each stacked sha to its current file path", () => {
|
||||
expect(stackToLivePaths(["b".repeat(40), "c".repeat(40)], files)).toEqual([
|
||||
"notes/one.md",
|
||||
"notes/two.md"
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps a sha verbatim when no path resolves (drifted snapshot)", () => {
|
||||
const orphan = "d".repeat(40)
|
||||
expect(stackToLivePaths(["b".repeat(40), orphan], files)).toEqual([
|
||||
"notes/one.md",
|
||||
orphan
|
||||
])
|
||||
})
|
||||
|
||||
it("preserves order", () => {
|
||||
expect(stackToLivePaths(["c".repeat(40), "a".repeat(40)], files)).toEqual([
|
||||
"notes/two.md",
|
||||
"README.md"
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveLivePathsToShas", () => {
|
||||
it("resolves each path to its latest sha", () => {
|
||||
expect(
|
||||
resolveLivePathsToShas(["notes/one.md", "notes/two.md"], files)
|
||||
).toEqual(["b".repeat(40), "c".repeat(40)])
|
||||
})
|
||||
|
||||
it("passes a sha-shaped entry through as a pinned fallback", () => {
|
||||
const orphan = "d".repeat(40)
|
||||
expect(resolveLivePathsToShas(["notes/one.md", orphan], files)).toEqual([
|
||||
"b".repeat(40),
|
||||
orphan
|
||||
])
|
||||
})
|
||||
|
||||
it("drops a renamed or deleted path so the view degrades gracefully", () => {
|
||||
expect(
|
||||
resolveLivePathsToShas(["notes/gone.md", "notes/two.md"], files)
|
||||
).toEqual(["c".repeat(40)])
|
||||
})
|
||||
|
||||
it("round-trips a live-shared stack back to the same shas", () => {
|
||||
const shas = ["a".repeat(40), "b".repeat(40)]
|
||||
expect(resolveLivePathsToShas(stackToLivePaths(shas, files), files)).toEqual(
|
||||
shas
|
||||
)
|
||||
})
|
||||
})
|
||||
38
src/modules/note/liveNotes.ts
Normal file
38
src/modules/note/liveNotes.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// A shared link pins each stacked note to a blob sha, so the recipient sees the
|
||||
// exact snapshot forever. A "living" link trades that pin for freshness: notes
|
||||
// are referenced by their file path instead, and re-resolved to the latest blob
|
||||
// sha against the repo's HEAD file list every time the link is opened.
|
||||
|
||||
const SHA_PATTERN = /^[0-9a-f]{40}$/i
|
||||
|
||||
/**
|
||||
* Encode the current stack (blob shas) as a living reference list. Each note
|
||||
* becomes its file path when one resolves from the HEAD file list, so the link
|
||||
* re-resolves to the latest version on open. A sha with no known path (e.g. an
|
||||
* already-drifted snapshot the sharer never pulled) is kept verbatim, staying
|
||||
* pinned — order is preserved either way.
|
||||
*/
|
||||
export const stackToLivePaths = (
|
||||
shas: ReadonlyArray<string>,
|
||||
files: ReadonlyArray<{ path?: string; sha?: string }>
|
||||
): string[] =>
|
||||
shas.map((sha) => files.find((file) => file.sha === sha)?.path ?? sha)
|
||||
|
||||
/**
|
||||
* Resolve a living reference list back to current blob shas against the HEAD
|
||||
* file list: a path maps to its latest sha; an entry already shaped like a sha
|
||||
* is passed through (the pinned fallback above); anything else — a renamed or
|
||||
* deleted path — is dropped so the view degrades gracefully instead of trying
|
||||
* to fetch a blob that no longer exists.
|
||||
*/
|
||||
export const resolveLivePathsToShas = (
|
||||
entries: ReadonlyArray<string>,
|
||||
files: ReadonlyArray<{ path?: string; sha?: string }>
|
||||
): string[] =>
|
||||
entries
|
||||
.map((entry) => {
|
||||
const latestSha = files.find((file) => file.path === entry)?.sha
|
||||
if (latestSha) return latestSha
|
||||
return SHA_PATTERN.test(entry) ? entry : null
|
||||
})
|
||||
.filter((sha): sha is string => sha !== null)
|
||||
@@ -204,6 +204,28 @@ pre {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Shikiji renders dual-theme tokens but only inlines the light colors;
|
||||
the dark palette lives in --shiki-dark* vars that we must apply ourselves.
|
||||
Without this, dark theme keeps the white code background and the
|
||||
base-content line numbers become white-on-white. */
|
||||
[data-theme="dracula"] :is(.shiki, .shiki span) {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) :is(.shiki, .shiki span) {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
}
|
||||
}
|
||||
|
||||
pre.mermaid {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -15,6 +15,7 @@ const EXT_TO_LANG: Record<string, string> = {
|
||||
scss: "css",
|
||||
json: "json",
|
||||
jsonc: "json",
|
||||
rs: "rust",
|
||||
als: "alloy"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computedAsync } from "@vueuse/core"
|
||||
import { useTitle } from "@vueuse/core"
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { computed, onMounted, ref, watch } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
|
||||
import HomeButton from "@/components/HomeButton.vue"
|
||||
@@ -112,10 +112,15 @@ const language = computed(() =>
|
||||
|
||||
const mainNoteId = computed(() => `${props.shortDid}-${props.rkey}`)
|
||||
|
||||
const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
|
||||
const { stackedNotes, scrollToFocusedNote, scrollToLastStackedNote } =
|
||||
useRouteQueryStackedNotes()
|
||||
const { listenToClick } = useATProtoLinks("note-display", { mainNoteId })
|
||||
useResizeContainer("note-container", stackedNotes)
|
||||
|
||||
onMounted(() => {
|
||||
scrollToLastStackedNote()
|
||||
})
|
||||
|
||||
useMarkdownPostRender(content, () => ".public-note-view .note-display", {
|
||||
onReady: () => listenToClick(),
|
||||
tikz: true,
|
||||
|
||||
Reference in New Issue
Block a user