diff --git a/web/app/components/DocumentHeader.nuxt.test.ts b/web/app/components/DocumentHeader.nuxt.test.ts index 764721ee..c855cff3 100644 --- a/web/app/components/DocumentHeader.nuxt.test.ts +++ b/web/app/components/DocumentHeader.nuxt.test.ts @@ -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("[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 @@ -88,10 +127,17 @@ describe("", { 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 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("", { 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, }) => { diff --git a/web/app/components/DocumentHeader.vue b/web/app/components/DocumentHeader.vue index 38e031fc..cdcc77c6 100644 --- a/web/app/components/DocumentHeader.vue +++ b/web/app/components/DocumentHeader.vue @@ -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") { + + + + {{ + isCompactView + ? $t("editor.navbar.document-options.view.full-title") + : $t( + "editor.navbar.document-options.view.compact-title", + ) + }} + + ", { 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("", { 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) diff --git a/web/app/components/editor/DocumentContainer.client.vue b/web/app/components/editor/DocumentContainer.client.vue index dec039b9..005ee808 100644 --- a/web/app/components/editor/DocumentContainer.client.vue +++ b/web/app/components/editor/DocumentContainer.client.vue @@ -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") { -->
{ 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) + }) }) diff --git a/web/app/composables/useEditorMeta.ts b/web/app/composables/useEditorMeta.ts index 18f2b5b5..f7994b9e 100644 --- a/web/app/composables/useEditorMeta.ts +++ b/web/app/composables/useEditorMeta.ts @@ -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({ + 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, diff --git a/web/i18n/locales/en/editor.json b/web/i18n/locales/en/editor.json index 24d39db7..840e7be3 100644 --- a/web/i18n/locales/en/editor.json +++ b/web/i18n/locales/en/editor.json @@ -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"