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; }`
|
- DaisyUI configured via `@plugin 'daisyui' { themes: retro --default, coffee --prefersdark; }`
|
||||||
- `@tailwindcss/typography` for prose styling
|
- `@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
|
### Directory Structure
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
|||||||
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
|
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
|
||||||
import { useNoteView } from "@/hooks/useNoteView.hook"
|
import { useNoteView } from "@/hooks/useNoteView.hook"
|
||||||
import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
|
import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
|
||||||
|
import { useResolveLiveNotes } from "@/hooks/useResolveLiveNotes.hook"
|
||||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||||
import { useVisitRepo } from "@/modules/history/hooks/useVisitRepo.hook"
|
import { useVisitRepo } from "@/modules/history/hooks/useVisitRepo.hook"
|
||||||
import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue"
|
import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue"
|
||||||
@@ -43,7 +44,12 @@ useUserSettings()
|
|||||||
const { visitRepo } = useVisitRepo({ user: user, repo: repo })
|
const { visitRepo } = useVisitRepo({ user: user, repo: repo })
|
||||||
const { toHTML } = markdownBuilder(repo)
|
const { toHTML } = markdownBuilder(repo)
|
||||||
const { listenToClick } = useLinks("note-display")
|
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 { titles } = useNoteView()
|
||||||
const { isLogged } = useGitHubLogin()
|
const { isLogged } = useGitHubLogin()
|
||||||
@@ -78,7 +84,10 @@ const retryLoad = () => {
|
|||||||
store.setUserRepo(props.user, props.repo)
|
store.setUserRepo(props.user, props.repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => visitRepo())
|
onMounted(() => {
|
||||||
|
visitRepo()
|
||||||
|
scrollToLastStackedNote()
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
store.resetFiles()
|
store.resetFiles()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import FontChange from "@/components/FontChange.vue"
|
import FontChange from "@/components/FontChange.vue"
|
||||||
import HomeButton from "@/components/HomeButton.vue"
|
import HomeButton from "@/components/HomeButton.vue"
|
||||||
|
import ShareNote from "@/components/ShareNote.vue"
|
||||||
|
|
||||||
defineProps<{ user: string; repo: string }>()
|
defineProps<{ user: string; repo: string }>()
|
||||||
</script>
|
</script>
|
||||||
@@ -55,6 +56,7 @@ defineProps<{ user: string; repo: string }>()
|
|||||||
<path d="M5 20l6 -16l2 0l7 16" />
|
<path d="M5 20l6 -16l2 0l7 16" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<share-note />
|
||||||
<router-link
|
<router-link
|
||||||
class="btn btn-ghost btn-circle"
|
class="btn btn-ghost btn-circle"
|
||||||
:to="{ name: 'FluxNoteView', params: { user, repo } }"
|
: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>
|
<script lang="ts" setup>
|
||||||
import {
|
import { computed, defineAsyncComponent, onMounted, ref, watch } from "vue"
|
||||||
computed,
|
|
||||||
defineAsyncComponent,
|
|
||||||
onMounted,
|
|
||||||
ref,
|
|
||||||
watch
|
|
||||||
} from "vue"
|
|
||||||
|
|
||||||
import { useEditionMode } from "@/hooks/useEditionMode"
|
import { useEditionMode } from "@/hooks/useEditionMode"
|
||||||
import { useFile } from "@/hooks/useFile.hook"
|
import { useFile } from "@/hooks/useFile.hook"
|
||||||
@@ -188,6 +182,19 @@ const loadNote = async () => {
|
|||||||
|
|
||||||
onMounted(loadNote)
|
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(
|
watch(
|
||||||
path,
|
path,
|
||||||
(p) => {
|
(p) => {
|
||||||
@@ -204,8 +211,7 @@ useMarkdownPostRender(content, () => `.note-${sha.value}`, {
|
|||||||
macroplan: true,
|
macroplan: true,
|
||||||
mermaid: () => rawContent.value.includes("```mermaid"),
|
mermaid: () => rawContent.value.includes("```mermaid"),
|
||||||
shikiji: () => isMarkdown.value && rawContent.value.includes("```"),
|
shikiji: () => isMarkdown.value && rawContent.value.includes("```"),
|
||||||
images: () =>
|
images: () => (/\!\[.*?\]\(.*?\)/.test(rawContent.value) ? props.sha : null),
|
||||||
/\!\[.*?\]\(.*?\)/.test(rawContent.value) ? props.sha : null,
|
|
||||||
triggers: [mode]
|
triggers: [mode]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -215,6 +221,13 @@ const performSave = async (overrideSha?: string) => {
|
|||||||
return
|
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 editedSha = overrideSha ?? (await getEditedSha()) ?? sha.value
|
||||||
const { sha: newSha, conflict } = await updateFile({
|
const { sha: newSha, conflict } = await updateFile({
|
||||||
content: rawContent.value,
|
content: rawContent.value,
|
||||||
@@ -287,8 +300,7 @@ watch(mode, async (newMode) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasUserFinishedToEdit =
|
const hasUserFinishedToEdit = newMode === "read" && isDirty.value
|
||||||
newMode === "read" && rawContent.value !== initialRawContent.value
|
|
||||||
|
|
||||||
if (!hasUserFinishedToEdit) {
|
if (!hasUserFinishedToEdit) {
|
||||||
return
|
return
|
||||||
@@ -336,7 +348,7 @@ const onBadgeClick = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasUnsavedEdits = rawContent.value !== initialRawContent.value
|
const hasUnsavedEdits = isDirty.value
|
||||||
if (hasUnsavedEdits) {
|
if (hasUnsavedEdits) {
|
||||||
await handleConflict()
|
await handleConflict()
|
||||||
return
|
return
|
||||||
@@ -377,7 +389,7 @@ const onBadgeClick = async () => {
|
|||||||
class="action"
|
class="action"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="isMarkdown && canPush"
|
v-if="isMarkdown && canPush && loadStatus === 'ready'"
|
||||||
class="action button is-text is-light"
|
class="action button is-text is-light"
|
||||||
:class="{ 'is-link': mode === 'edit' }"
|
:class="{ 'is-link': mode === 'edit' }"
|
||||||
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''"
|
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''"
|
||||||
@@ -489,7 +501,7 @@ const onBadgeClick = async () => {
|
|||||||
<span>You're viewing an older shared version.</span>
|
<span>You're viewing an older shared version.</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="snapshot-banner-action"
|
class="btn bt-sm btn-soft btn-info"
|
||||||
@click="viewLatest"
|
@click="viewLatest"
|
||||||
>
|
>
|
||||||
View latest
|
View latest
|
||||||
@@ -588,16 +600,6 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
font-size: 0.85em;
|
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 {
|
.action {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
|
|||||||
@@ -381,10 +381,10 @@ onMounted(() => {
|
|||||||
comes to rest.
|
comes to rest.
|
||||||
</h1>
|
</h1>
|
||||||
<p class="lede">
|
<p class="lede">
|
||||||
Point it at any markdown — a GitHub repo of your own, or public
|
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
|
notes from the open ATProto network. Remanso turns it into a
|
||||||
calm, stackable notebook with backlinks where your thinking
|
stackable notebook with backlinks where your thinking finally
|
||||||
finally runs clear.
|
runs clear.
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-ed-paths">
|
<div class="hero-ed-paths">
|
||||||
<!-- CTA 01: GitHub repo -->
|
<!-- CTA 01: GitHub repo -->
|
||||||
@@ -472,7 +472,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="publicNotesLoadFailed" class="network-empty">
|
<div v-else-if="publicNotesLoadFailed" class="network-empty">
|
||||||
The pool is quiet right now.
|
Nothing has drifted in yet.
|
||||||
</div>
|
</div>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'PublicNoteListView' }"
|
:to="{ name: 'PublicNoteListView' }"
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ export const useShikiji = (): Promise<void> => {
|
|||||||
"html",
|
"html",
|
||||||
"css",
|
"css",
|
||||||
"json",
|
"json",
|
||||||
|
"rust",
|
||||||
{
|
{
|
||||||
...alloyGrammar,
|
...alloyGrammar,
|
||||||
name: "alloy",
|
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 = (
|
const addStackedNote = (
|
||||||
currentSha: string,
|
currentSha: string,
|
||||||
sha: string,
|
sha: string,
|
||||||
@@ -168,6 +178,7 @@ export const useRouteQueryStackedNotes = () => {
|
|||||||
stackedNotes: readonly(stackedNotes),
|
stackedNotes: readonly(stackedNotes),
|
||||||
addStackedNote,
|
addStackedNote,
|
||||||
replaceStackedNote,
|
replaceStackedNote,
|
||||||
scrollToFocusedNote
|
scrollToFocusedNote,
|
||||||
|
scrollToLastStackedNote
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,6 +232,10 @@ const bandStyle = computed(() => ({
|
|||||||
display: grid;
|
display: grid;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
min-width: 100%;
|
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;
|
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;
|
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 {
|
pre.mermaid {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const EXT_TO_LANG: Record<string, string> = {
|
|||||||
scss: "css",
|
scss: "css",
|
||||||
json: "json",
|
json: "json",
|
||||||
jsonc: "json",
|
jsonc: "json",
|
||||||
|
rs: "rust",
|
||||||
als: "alloy"
|
als: "alloy"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computedAsync } from "@vueuse/core"
|
import { computedAsync } from "@vueuse/core"
|
||||||
import { useTitle } 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 { useRouter } from "vue-router"
|
||||||
|
|
||||||
import HomeButton from "@/components/HomeButton.vue"
|
import HomeButton from "@/components/HomeButton.vue"
|
||||||
@@ -112,10 +112,15 @@ const language = computed(() =>
|
|||||||
|
|
||||||
const mainNoteId = computed(() => `${props.shortDid}-${props.rkey}`)
|
const mainNoteId = computed(() => `${props.shortDid}-${props.rkey}`)
|
||||||
|
|
||||||
const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
|
const { stackedNotes, scrollToFocusedNote, scrollToLastStackedNote } =
|
||||||
|
useRouteQueryStackedNotes()
|
||||||
const { listenToClick } = useATProtoLinks("note-display", { mainNoteId })
|
const { listenToClick } = useATProtoLinks("note-display", { mainNoteId })
|
||||||
useResizeContainer("note-container", stackedNotes)
|
useResizeContainer("note-container", stackedNotes)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
scrollToLastStackedNote()
|
||||||
|
})
|
||||||
|
|
||||||
useMarkdownPostRender(content, () => ".public-note-view .note-display", {
|
useMarkdownPostRender(content, () => ".public-note-view .note-display", {
|
||||||
onReady: () => listenToClick(),
|
onReady: () => listenToClick(),
|
||||||
tikz: true,
|
tikz: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user