add compact document view for larger screens

This commit is contained in:
david 2026-09-17 21:35:20 +03:00
parent 3b7ef3c5c5
commit 929552afef
9 changed files with 204 additions and 5 deletions

View File

@ -9,10 +9,12 @@ import {
import DocumentHeader from "./DocumentHeader.vue"
import {
at,
clearTeleportedOverlays,
findButtonByText,
menuItem,
mountUnderSidebarProvider,
seedCapabilities,
seedPersistentState,
settleMutations,
t,
} from "./test-helpers"
@ -72,6 +74,43 @@ async function openOptionsMenu(
await at(triggers, triggers.length - 1).trigger("click")
}
function menuItemTexts() {
return Array.from(
document.body.querySelectorAll<HTMLElement>("[role^='menuitem']"),
).map((el) => el.textContent.trim())
}
// happy-dom lays nothing out, so useElementSize reports 0 until a resize
// observer hands it a size; this one answers every observe with the given
// width. Restored by hand: vi.unstubAllGlobals would also undo the globals
// the nuxt runtime stubbed for the whole suite
const realResizeObserver = window.ResizeObserver
function stubMainWidth(width: number) {
window.ResizeObserver = class {
constructor(private callback: ResizeObserverCallback) {}
observe(target: Element) {
const size = { inlineSize: width, blockSize: 40 }
this.callback(
[
{
target,
contentRect: { width, height: 40 },
contentBoxSize: [size],
borderBoxSize: [size],
} as unknown as ResizeObserverEntry,
],
this,
)
}
unobserve = vi.fn()
disconnect = vi.fn()
}
}
// the editor store, the query cache and the vue-sonner module mock are all
// app-wide singletons every mount in the file shares, and the menus are
// teleported into the shared <body>
@ -88,10 +127,17 @@ describe("<DocumentHeader>", { concurrent: false }, () => {
editorStore.mappedDefaultBranchId = MAIN_BRANCH
editorStore.aiAssistantOpen = false
useEditorMeta().setEditable(true)
seedPersistentState("editor-compact-view", true)
editorStore.setActiveBranchProtected(false)
})
afterEach(disposeMockEndpoints)
afterEach(() => {
disposeMockEndpoints()
window.ResizeObserver = realResizeObserver
// the options menus teleport into the shared <body> and their ids
// repeat across mounts, so a leftover would answer the next lookup
clearTeleportedOverlays()
})
it("stays blank until the initial sections have loaded", async ({
expect,
@ -273,6 +319,63 @@ describe("<DocumentHeader>", { concurrent: false }, () => {
expect(editorStore.targetBranchId).toBeNull()
})
it("hides the view toggle while the main area is no wider than the compact column", async ({
expect,
}) => {
mainOnly()
const wrapper = await mountHeader()
await openOptionsMenu(wrapper)
expect(menuItemTexts()).not.toContain(
t("editor.navbar.document-options.view.full-title"),
)
expect(menuItemTexts()).not.toContain(
t("editor.navbar.document-options.view.compact-title"),
)
})
it("offers the full view first once the main area is wider than the compact column", async ({
expect,
}) => {
stubMainWidth(1400)
mainOnly()
const wrapper = await mountHeader()
await openOptionsMenu(wrapper)
expect(at(menuItemTexts(), 0)).toBe(
t("editor.navbar.document-options.view.full-title"),
)
})
it("switches to the full view from the options menu", async ({ expect }) => {
stubMainWidth(1400)
mainOnly()
const wrapper = await mountHeader()
await openOptionsMenu(wrapper)
menuItem(t("editor.navbar.document-options.view.full-title")).click()
await nextTick()
expect(useEditorMeta().isCompactView.value).toBe(false)
})
it("offers the compact view while the full view is active", async ({
expect,
}) => {
stubMainWidth(1400)
mainOnly()
useEditorMeta().toggleCompactView()
const wrapper = await mountHeader()
await openOptionsMenu(wrapper)
expect(at(menuItemTexts(), 0)).toBe(
t("editor.navbar.document-options.view.compact-title"),
)
})
it("asks to duplicate the document from the options menu", async ({
expect,
}) => {

View File

@ -12,7 +12,13 @@ const emit = defineEmits<{
(e: "delete-document" | "duplicate-document"): void
}>()
const { isEditable, isBranchProtected, toggleIsEditable } = useEditorMeta()
const {
isEditable,
isBranchProtected,
toggleIsEditable,
isCompactView,
toggleCompactView,
} = useEditorMeta()
const editorStore = useEditorStore()
const { isAssistantEnabled } = useCapabilitiesAPI()
const { useFetchDocumentBranchesByDocId } = useDocumentAPI()
@ -20,7 +26,10 @@ const fetchBranches = useFetchDocumentBranchesByDocId(
() => editorStore.activeDocumentId,
)
const header = useTemplateRef("header")
const { height } = useElementSize(header)
const { height, width } = useElementSize(header)
const viewToggleVisible = computed(
() => width.value > EDITOR_COMPACT_MAX_WIDTH_PX,
)
const { t } = useI18n({ useScope: "global" })
const { updateDocumentBranch, createDocumentBranch, deleteDocumentBranch } =
useDocumentAPI()
@ -335,6 +344,25 @@ function activateBranch(branch: "default" | "draft") {
</ShadcnUiButton>
</ShadcnUiDropdownMenuTrigger>
<ShadcnUiDropdownMenuContent side="bottom" align="end" loop>
<ShadcnUiDropdownMenuItem
v-if="viewToggleVisible"
@click="toggleCompactView"
>
<Icon
:name="
isCompactView ? 'lucide:maximize-2' : 'lucide:minimize-2'
"
/>
<span>
{{
isCompactView
? $t("editor.navbar.document-options.view.full-title")
: $t(
"editor.navbar.document-options.view.compact-title",
)
}}
</span>
</ShadcnUiDropdownMenuItem>
<ShadcnUiDropdownMenuItem @click="toggleReviewability">
<Icon
:name="

View File

@ -13,6 +13,7 @@ import {
at,
emitFrom,
seedAuthSession,
seedPersistentState,
settleMutations,
WAIT_FOR_OPTIONS,
} from "~/components/test-helpers"
@ -185,6 +186,7 @@ describe("<DocumentContainer>", { concurrent: false }, () => {
redirectToLogin.mockReset()
clearQueryCache()
useEditorMeta().setEditable(true)
seedPersistentState("editor-compact-view", true)
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
useEditorStore().updateTargetBranchId(null)
@ -518,6 +520,33 @@ describe("<DocumentContainer>", { concurrent: false }, () => {
expect(useEditorMeta().isEditable.value).toBe(false)
})
it("caps and centers the editor column in compact view", async ({
expect,
}) => {
const wrapper = await mountContainer()
await sync(BRANCH_ID)
const column = wrapper.find(".editor-scroll-highlight-container")
expect(column.classes()).toContain("max-w-320")
expect(column.classes()).toContain("mx-auto")
expect(column.attributes("data-compact-view")).toBe("")
})
it("lets the editor column fill the page in full view", async ({
expect,
}) => {
useEditorMeta().toggleCompactView()
const wrapper = await mountContainer()
await sync(BRANCH_ID)
const column = wrapper.find(".editor-scroll-highlight-container")
expect(column.classes()).not.toContain("max-w-320")
expect(column.classes()).not.toContain("mx-auto")
expect(column.attributes("data-compact-view")).toBeUndefined()
})
it("passes the content editor on once it is ready", async ({ expect }) => {
const wrapper = await mountContainer()
await sync(BRANCH_ID)

View File

@ -41,7 +41,7 @@ const { useFetchDocumentBranchesByDocId } = useDocumentAPI()
const fetchBranches = useFetchDocumentBranchesByDocId(
() => editorStore.activeDocumentId,
)
const { isEditable } = useEditorMeta()
const { isEditable, isCompactView } = useEditorMeta()
const route = useRoute()
const nuxtApp = useNuxtApp()
const fetchDocumentHooks = documentHookAPI.useFetchDocumentHooksByDocID(
@ -370,9 +370,11 @@ function showSettings(target: "github") {
-->
<div
:key="editorStore.activeBranchId"
:data-compact-view="isCompactView ? '' : undefined"
:class="[
SCROLL_HIGHLIGHT_EDITOR_CLASS,
'relative flex h-full w-full flex-col items-center transition-opacity duration-300',
isCompactView && 'mx-auto max-w-320',
readyToReveal ? 'opacity-100' : 'pointer-events-none opacity-0',
]"
@transitionend.self="emit('fade-in-complete')"

View File

@ -380,7 +380,7 @@ async function executeReviewableAction() {
>
<div
v-show="hookStatus === 'stale'"
class="pointer-events-none absolute top-1/2 -left-5 h-full w-1.25 -translate-y-1/2 rounded-r-lg bg-hook-decoration lg:-left-12.5 lg:h-[calc(100%+2rem)]"
class="pointer-events-none absolute top-1/2 -left-5 h-full w-1.25 -translate-y-1/2 rounded-r-lg bg-hook-decoration in-data-compact-view:rounded-full lg:-left-12.5 lg:h-[calc(100%+2rem)]"
/>
<div
class="flex flex-col-reverse items-start gap-2 sm:flex-row sm:justify-between"

View File

@ -146,6 +146,9 @@ function buildDecorations(
el.className = cn(
"pointer-events-none absolute top-0",
"w-1.25 bg-hook-decoration block rounded-r-lg",
// the bar hugs the page edge in full view; in compact view the
// column floats, so it becomes a pill
"in-data-compact-view:rounded-full",
placeOutside && "m-0!",
)
el.ignoreMutation = true

View File

@ -74,4 +74,20 @@ describe("useEditorMeta", { concurrent: false }, () => {
expect(isReadonly(meta.isEditable)).toBe(true)
})
it("starts in compact view", ({ expect }) => {
const meta = useEditorMeta()
expect(meta.isCompactView.value).toBe(true)
})
it("switches to full view and back", ({ expect }) => {
const meta = useEditorMeta()
meta.toggleCompactView()
expect(meta.isCompactView.value).toBe(false)
meta.toggleCompactView()
expect(meta.isCompactView.value).toBe(true)
})
})

View File

@ -1,3 +1,5 @@
export const EDITOR_COMPACT_MAX_WIDTH_PX = 1280
export default function () {
// the reader's own read/edit choice. Whether the page can actually be
// edited also depends on the branch, so consumers read isEditable
@ -6,6 +8,12 @@ export default function () {
defaultValue: true,
})
// compact view caps and centers the editor column on wide screens
const compactViewPreference = usePersistentState<boolean>({
key: "editor-compact-view",
defaultValue: true,
})
const editorStore = useEditorStore()
const isEditable = computed(
@ -20,6 +28,10 @@ export default function () {
editablePreference.value = v
}
function toggleCompactView() {
compactViewPreference.value = !compactViewPreference.value
}
function updateLock(v: boolean) {
editorStore.updateLock(v)
}
@ -28,6 +40,8 @@ export default function () {
isEditable,
toggleIsEditable,
setEditable,
isCompactView: computed(() => compactViewPreference.value),
toggleCompactView,
isBranchProtected: computed(() => editorStore.activeBranchProtected),
isLocked: computed(() => editorStore.locked), // locks are used by drag handle menus
updateLock,

View File

@ -75,6 +75,10 @@
}
},
"document-options": {
"view": {
"full-title": "Full View",
"compact-title": "Compact View"
},
"review-workflow": {
"enable-title": "Make Reviewable",
"disable-title": "Make Non Reviewable"