oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3// The menu bar's model and copy — File / Edit / View / Format / Tools,
4// rendered by views/show.html on estilo's [estilo-menubar] and
5// [estilo-menu] (the one menu style the suite shares) and given its
6// keyboard behaviour by fixtures/menu.mjs.
7//
8// It replaces the two export links that used to be pinned to the
9// trailing end of the formatting bar. Those were the "random buttons"
10// of Paul's brief, and the objection was structural rather than
11// aesthetic: export is a document-level verb sitting inside a group of
12// run-level formatting controls, which is the one place a person will
13// not look for it.
14//
15// Three properties this file exists to hold, all of them easy to lose
16// later:
17//
18// 1. THE MENU WORKS WITHOUT JAVASCRIPT. Each menu is a <details>, so
19// it opens with no script at all, and every item that can be a
20// plain <a> or a <form> is one. That is not a nicety here: the
21// export links worked scriptless before this change
22// (TestShowRendersContentWithoutScript is built on the same
23// guarantee), and moving them behind a control that only opens
24// under JavaScript would have quietly removed the only route to a
25// person's own document. Items that genuinely need the editor —
26// Bold, Undo — are Kind "command" and are disabled until the
27// bundle enables them, which is honest: without scripts there is
28// no editor for them to act on.
29//
30// 2. THE MENU AND THE TOOLBAR ARE ONE COMMAND TABLE. A "command" item
31// carries the same data-docs-command name the toolbar's buttons
32// carry, resolved against the same table in fixtures/toolbar.mjs.
33// TestMenuCommandsExistInTheBundle holds every name in this file to
34// that file's source, the same way
35// TestToolbarOptionValuesMatchTheBundle already holds the
36// toolbar's. A menu item naming a command nobody implements is a
37// test failure here, not a silent no-op in front of a person.
38//
39// 3. NO SUBMENUS. rastrillo's rst-menus has a trap where a nested
40// <details> inside a name= exclusive group collapses its parent,
41// and the Sheets session hit it first. The response is not to work
42// around it but to have no second level: "Download as PDF" and
43// "Download as Markdown" are siblings rather than children of a
44// "Download" submenu. Fewer levels is the better menu anyway — the
45// submenus were Google Docs' shape copied rather than chosen.
46//
47// Every string is a catalog key resolved through rastrillo.T at render
48// time. A literal here is a bug (AGENTS.md).
49
50import (
51 "net/http"
52
53 estilomenu "amadan.net/oficina/estilo.git/menu"
54 "amadan.net/rastrillo/rastrillo"
55)
56
57// Menu item kinds. The kind decides what show.html renders, and each
58// one is a different HTML element rather than a <button> styled to look
59// like the others — which is what keeps the scriptless half working.
60const (
61 // menuCommand dispatches Command into the editor's command table.
62 // Rendered as a <button type="button">, disabled until the bundle
63 // enables it.
64 menuCommand = "command"
65 // menuLink is an ordinary <a href>. Works with no script.
66 menuLink = "link"
67 // menuForm is a one-button <form method="post">. Works with no
68 // script. Used for the two state-changing verbs that are not
69 // editor commands: creating a document and signing out.
70 menuForm = "form"
71 // menuDivider is a horizontal rule between groups of items. It is
72 // a real <hr> rather than a border on the next item, so it
73 // announces as a separator rather than as nothing.
74 menuDivider = "divider"
75)
76
77// menuItem is one row in one menu.
78//
79// Every item works in this build. An item whose feature is not built is
80// not in the menu at all (spec §7.3: hidden, not disabled): the Insert
81// menu, Find and replace and Show ruler used to be rendered disabled
82// with the spec section each was waiting on, and a bar of greyed-out
83// promises is the main thing that made the editor read as a prototype.
84// Disabled is now only ever contextual — Redo with nothing to redo —
85// and that is decided by the command itself in fixtures/menu.mjs.
86type menuItem struct {
87 Kind string
88 Command string // menuCommand: the data-docs-command name
89 Href string // menuLink: the target
90 Action string // menuForm: the POST target
91 Download bool // menuLink: adds the download attribute
92 // JumpTo marks the File menu's "Open a document…": a link to the
93 // list that fixtures/jump-to.mjs turns into the ⌘K palette.
94 JumpTo bool
95 Label string
96 // Shortcut is the chord, already formatted for the request's
97 // platform by estilo's menu.Shortcut (⇧⌘Z on a Mac, Ctrl+Y
98 // elsewhere), so nothing flips after paint. The binding itself lives
99 // in the bundle's keymap.
100 Shortcut string
101 // Checkable items report a state with estilo's check column: the View
102 // menu's paged/infinite pair, the Format menu's paragraph styles and
103 // alignments. Each is a one-of group, so exactly one is pressed.
104 //
105 // Pressed is rendered server-side where the server knows the state
106 // (the view mode, from its cookie), so the menu is right before any
107 // script runs; fixtures/menu.mjs keeps every checkable item in step
108 // with the editor afterwards, on every state update and after a view
109 // switch.
110 Checkable bool
111 Pressed bool
112}
113
114// menu is one top-level <details> in the bar.
115type menu struct {
116 // ID is the DOM id stem, used to tie the <summary> to its item
117 // list. Not a catalog key — it never reaches a person.
118 ID string
119 Title string
120 Items []menuItem
121}
122
123// Checks reports whether the menu has any checkable item. Such a menu
124// renders the check column on every row, so labels stay aligned; a plain
125// menu has no empty gutter.
126func (m menu) Checks() bool {
127 for _, it := range m.Items {
128 if it.Checkable {
129 return true
130 }
131 }
132 return false
133}
134
135// menuView is what views/show.html renders.
136type menuView struct {
137 // Label is the bar's own accessible name.
138 Label string
139 Menus []menu
140}
141
142// newMenuView builds the bar's copy for one request.
143//
144// sqid is needed because two items are per-document links (the two
145// exports). Everything else is the same on every document, but the
146// whole structure is built per request regardless, because every label
147// is resolved through rastrillo.T against the request's locale — a
148// package-level var built once would be built in whichever locale
149// happened to serve first.
150// The two page-view modes, and the cookie that carries a person's
151// choice between them.
152//
153// A COOKIE, not localStorage, and the reason is the first paint. The
154// server has to know the mode to render [docs-canvas]'s data-view, or a
155// person who prefers paged watches an infinite document reflow into
156// pages the moment the deferred bundle runs. localStorage is invisible
157// here; a cookie is not.
158//
159// PER PERSON, not per document. How you like to read is not something
160// to impose on a collaborator, and nothing about it belongs in the CRDT
161// — see fixtures/pages.mjs's head comment for why a persisted page
162// break would be an edit two people could fight over.
163const (
164 ViewPaged = "paged"
165 ViewInfinite = "infinite"
166 ViewCookie = "docs-view"
167)
168
169// viewMode reads the mode from the request, defaulting to infinite.
170//
171// Closed in the strong sense, like every other value this app takes off
172// the wire: anything that is not exactly "paged" is infinite. The
173// cookie is written by fixtures/pages.mjs and is therefore a value a
174// person can set to anything at all, and it lands in an HTML attribute
175// — so it is a key into this comparison and never a string that gets
176// printed. Same discipline as fragment.rs's ALIGNMENTS, for the same
177// reason.
178func viewMode(r *http.Request) string {
179 c, err := r.Cookie(ViewCookie)
180 if err != nil || c.Value != ViewPaged {
181 return ViewInfinite
182 }
183 return ViewPaged
184}
185
186// fileItems is the File menu, whose first entry is the one thing in this
187// bar a guest may not do.
188//
189// "New document" is a plain form posting /documents, which a guest is
190// answered 403 for. A control that exists only to fail is the same defect
191// as the editor a reader could type into, in a smaller place — so it is
192// absent for them rather than present and refused.
193func fileItems(r *http.Request, sqid string, canCreate bool) []menuItem {
194 items := make([]menuItem, 0, 8)
195 if canCreate {
196 items = append(items, menuItem{Kind: menuForm, Action: "/documents", Label: rastrillo.T(r, "docs.editor.menu.file.new")})
197 }
198 // Sign out used to be the last item here too. It is in the account
199 // menu and nowhere else now (spec §8.2): once, in the same corner, in
200 // every app.
201 return append(items,
202 menuItem{Kind: menuLink, Href: "/", JumpTo: true, Label: rastrillo.T(r, "docs.editor.menu.file.open"), Shortcut: estilomenu.Shortcut(r, "Mod+K")},
203 menuItem{Kind: menuDivider},
204 // The two exports, moved here from [docs-doc-actions]. Still plain
205 // links with the download attribute, so they behave identically to
206 // before for anyone who had learned where they were — the route
207 // did not change, only where it is named.
208 menuItem{Kind: menuLink, Href: "/d/" + sqid + "/export.pdf", Download: true, Label: rastrillo.T(r, "docs.export.pdf.label")},
209 menuItem{Kind: menuLink, Href: "/d/" + sqid + "/export.md", Download: true, Label: rastrillo.T(r, "docs.export.markdown.label")},
210 menuItem{Kind: menuCommand, Command: "print", Label: rastrillo.T(r, "docs.editor.menu.file.print"), Shortcut: estilomenu.Shortcut(r, "Mod+P")},
211 )
212}
213
214// redoChord is the redo binding a person on this platform expects. The
215// keymap binds both (editor-entry.js: Mod-y and Mod-Shift-z); the label
216// names the one each platform's own editors use.
217func redoChord(r *http.Request) string {
218 if estilomenu.Apple(r) {
219 return "Mod+Shift+Z"
220 }
221 return "Mod+Y"
222}
223
224func newMenuView(r *http.Request, sqid string, canCreate bool) menuView {
225 mode := viewMode(r)
226 key := func(chord string) string { return estilomenu.Shortcut(r, chord) }
227 return menuView{
228 Label: rastrillo.T(r, "docs.editor.menu.label"),
229 Menus: []menu{
230 {
231 ID: "file",
232 Title: rastrillo.T(r, "docs.editor.menu.file"),
233 Items: fileItems(r, sqid, canCreate),
234 },
235 {
236 ID: "edit",
237 Title: rastrillo.T(r, "docs.editor.menu.edit"),
238 Items: []menuItem{
239 {Kind: menuCommand, Command: "undo", Label: rastrillo.T(r, "docs.editor.toolbar.undo"), Shortcut: key("Mod+Z")},
240 {Kind: menuCommand, Command: "redo", Label: rastrillo.T(r, "docs.editor.toolbar.redo"), Shortcut: key(redoChord(r))},
241 {Kind: menuDivider},
242 {Kind: menuCommand, Command: "select_all", Label: rastrillo.T(r, "docs.editor.menu.edit.selectall"), Shortcut: key("Mod+A")},
243 },
244 },
245 {
246 ID: "view",
247 Title: rastrillo.T(r, "docs.editor.menu.view"),
248 Items: []menuItem{
249 // A pair rather than one toggle, so the current mode
250 // is readable without interpreting a checkmark's
251 // absence. Exactly one is pressed.
252 //
253 // aria-pressed on a <button>, NOT aria-checked:
254 // aria-checked belongs to menuitemcheckbox and
255 // menuitemradio, and these menus are disclosures
256 // whose items are ordinary buttons (see the markup's
257 // own comment for why). Using the attribute that
258 // does not apply to the element is worse than using
259 // none.
260 {Kind: menuCommand, Command: "view_paged", Label: rastrillo.T(r, "docs.editor.menu.view.paged"), Checkable: true, Pressed: mode == ViewPaged},
261 {Kind: menuCommand, Command: "view_infinite", Label: rastrillo.T(r, "docs.editor.menu.view.infinite"), Checkable: true, Pressed: mode != ViewPaged},
262 },
263 },
264 {
265 ID: "format",
266 Title: rastrillo.T(r, "docs.editor.menu.format"),
267 Items: []menuItem{
268 // Every one of these already exists in
269 // fixtures/toolbar.mjs's command table. The menu
270 // adds no behaviour here at all — it is a second
271 // surface onto the same names, which is the whole
272 // reason property 2 above is worth a test.
273 {Kind: menuCommand, Command: "strong", Label: rastrillo.T(r, "docs.editor.toolbar.bold"), Shortcut: key("Mod+B")},
274 {Kind: menuCommand, Command: "em", Label: rastrillo.T(r, "docs.editor.toolbar.italic"), Shortcut: key("Mod+I")},
275 {Kind: menuCommand, Command: "code", Label: rastrillo.T(r, "docs.editor.toolbar.code")},
276 {Kind: menuDivider},
277 // The paragraph styles, checked for the one the caret
278 // is in. "Body text" used to show as DISABLED there,
279 // because setting the style it already has does
280 // nothing: disabled standing in for current.
281 //
282 // The command names match the block select's own
283 // option values (export.go's Blocks), so the two
284 // controls set the same thing by the same name.
285 {Kind: menuCommand, Command: "block_paragraph", Label: rastrillo.T(r, "docs.editor.toolbar.block.paragraph"), Checkable: true},
286 {Kind: menuCommand, Command: "block_heading-1", Label: rastrillo.T(r, "docs.editor.toolbar.block.heading1"), Checkable: true},
287 {Kind: menuCommand, Command: "block_heading-2", Label: rastrillo.T(r, "docs.editor.toolbar.block.heading2"), Checkable: true},
288 {Kind: menuCommand, Command: "block_heading-3", Label: rastrillo.T(r, "docs.editor.toolbar.block.heading3"), Checkable: true},
289 {Kind: menuDivider},
290 {Kind: menuCommand, Command: "align_left", Label: rastrillo.T(r, "docs.editor.toolbar.align.left"), Checkable: true},
291 {Kind: menuCommand, Command: "align_center", Label: rastrillo.T(r, "docs.editor.toolbar.align.center"), Checkable: true},
292 {Kind: menuCommand, Command: "align_right", Label: rastrillo.T(r, "docs.editor.toolbar.align.right"), Checkable: true},
293 {Kind: menuDivider},
294 {Kind: menuCommand, Command: "bullet_list", Label: rastrillo.T(r, "docs.editor.toolbar.bulletlist")},
295 {Kind: menuCommand, Command: "ordered_list", Label: rastrillo.T(r, "docs.editor.toolbar.orderedlist")},
296 {Kind: menuCommand, Command: "blockquote", Label: rastrillo.T(r, "docs.editor.toolbar.quote")},
297 {Kind: menuDivider},
298 // The same two commands the Tab key runs, by the
299 // same names — see fixtures/indent.mjs and the
300 // keymap in editor-entry.js. The shortcut shown
301 // here is therefore a statement of fact rather than
302 // a second binding.
303 {Kind: menuCommand, Command: "indent", Label: rastrillo.T(r, "docs.editor.menu.format.indent"), Shortcut: key("Tab")},
304 {Kind: menuCommand, Command: "outdent", Label: rastrillo.T(r, "docs.editor.menu.format.outdent"), Shortcut: key("Shift+Tab")},
305 {Kind: menuDivider},
306 {Kind: menuCommand, Command: "clear_formatting", Label: rastrillo.T(r, "docs.editor.menu.format.clear")},
307 },
308 },
309 {
310 ID: "tools",
311 Title: rastrillo.T(r, "docs.editor.menu.tools"),
312 Items: []menuItem{
313 {Kind: menuCommand, Command: "word_count", Label: rastrillo.T(r, "docs.editor.menu.tools.wordcount")},
314 {Kind: menuDivider},
315 {Kind: menuCommand, Command: "shortcuts", Label: rastrillo.T(r, "docs.editor.menu.tools.shortcuts")},
316 },
317 },
318 },
319 }
320}
321
322// menuCommandNames is every command name the bar names, in order, with
323// duplicates kept — internal/docs's TestMenuCommandsExistInTheBundle
324// resolves each against fixtures/toolbar.mjs's source, and
325// TestMenuStructureIsWellFormed checks the shape of the rest.
326//
327// It reads the built view rather than a second list, which is the point:
328// a list maintained beside newMenuView is a list that stops matching it,
329// and the failure mode is a test that passes by checking a name nobody
330// renders.
331func menuCommandNames(v menuView) []string {
332 var out []string
333 for _, m := range v.Menus {
334 for _, it := range m.Items {
335 if it.Kind == menuCommand {
336 out = append(out, it.Command)
337 }
338 }
339 }
340 return out
341}
342