| 1 | package 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 | |
| 50 | import ( |
| 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. |
| 60 | const ( |
| 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. |
| 86 | type 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. |
| 115 | type 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. |
| 126 | func (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. |
| 136 | type 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. |
| 163 | const ( |
| 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. |
| 178 | func 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. |
| 193 | func 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. |
| 217 | func redoChord(r *http.Request) string { |
| 218 | if estilomenu.Apple(r) { |
| 219 | return "Mod+Shift+Z" |
| 220 | } |
| 221 | return "Mod+Y" |
| 222 | } |
| 223 | |
| 224 | func 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. |
| 331 | func 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 | |