diff --git a/resources/dist/mail-editor.css b/resources/dist/mail-editor.css index c11d897..f026307 100644 --- a/resources/dist/mail-editor.css +++ b/resources/dist/mail-editor.css @@ -99,7 +99,6 @@ odden-mail-editor { .ome-button, .ome-icon-button, -.ome-palette-item, .ome-structure-select, .ome-handle { background: var(--ome-surface); @@ -136,7 +135,6 @@ odden-mail-editor { .ome-button:hover:not(:disabled), .ome-icon-button:hover:not(:disabled), -.ome-palette-item:hover, .ome-structure-select:hover { background: #f8fafc; border-color: #cbd5e1; @@ -154,27 +152,99 @@ odden-mail-editor { /* Palette */ -.ome-palette { +.ome-tabs { + background: var(--ome-canvas); + border-radius: 8px; + display: grid; + gap: 2px; + grid-template-columns: 1fr 1fr; + margin-bottom: 12px; + padding: 2px; +} + +.ome-tab { + background: transparent; + border: 0; + border-radius: 6px; + color: var(--ome-muted); + cursor: pointer; + font: inherit; + font-weight: 600; + min-height: 30px; +} + +.ome-tab[aria-selected='true'] { + background: var(--ome-surface); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12); + color: var(--ome-ink); +} + +.ome-pane[hidden] { + display: none; +} + +.ome-search { + margin-bottom: 12px; + width: 100%; +} + +.ome-group-heading { + color: var(--ome-muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + margin: 14px 0 6px; + text-transform: uppercase; +} + +.ome-palette-group:first-child .ome-group-heading { + margin-top: 0; +} + +.ome-tiles { display: grid; gap: 6px; - grid-template-columns: 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); } -.ome-palette-item { +.ome-tile { align-items: center; + background: var(--ome-surface); + border: 1px solid var(--ome-line); + border-radius: 8px; + color: var(--ome-ink); cursor: grab; display: flex; - gap: 8px; - min-height: 34px; - padding: 4px 10px; - text-align: left; + flex-direction: column; + font: inherit; + font-size: 12px; + gap: 6px; + justify-content: center; + min-height: 72px; + padding: 10px 6px; + text-align: center; touch-action: none; user-select: none; } -.ome-palette-glyph { +.ome-tile:hover { + background: var(--ome-accent-soft); + border-color: var(--ome-accent); +} + +.ome-tile-icon { color: var(--ome-accent); - font-weight: 700; + display: inline-flex; +} + +.ome-tile-icon svg { + height: 24px; + width: 24px; +} + +.ome-tile-label { + line-height: 1.25; + overflow-wrap: anywhere; } /* Structure list */ diff --git a/resources/dist/mail-editor.js b/resources/dist/mail-editor.js index 6070298..6174b04 100644 --- a/resources/dist/mail-editor.js +++ b/resources/dist/mail-editor.js @@ -43,6 +43,31 @@ function clear(element) { } } +const SVG_NS = 'http://www.w3.org/2000/svg'; + +/** A decorative 24x24 outline icon from path data (the `d` of each path). */ +function icon(paths) { + const svg = document.createElementNS(SVG_NS, 'svg'); + + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '1.5'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('focusable', 'false'); + + for (const d of paths) { + const path = document.createElementNS(SVG_NS, 'path'); + + path.setAttribute('d', d); + svg.append(path); + } + + return svg; +} + // ---- model.js // The editor's document model: an ordered list of slots, `{id, type, data, visibility?}`. Every function returns a new // list and never changes its argument, so the history can keep snapshots cheaply. @@ -267,6 +292,37 @@ function dropLineY(rects, index) { return index >= rects.length ? rects[rects.length - 1].bottom : rects[index].top; } +/** + * The blocks of the palette for a search text, in groups: `[{key, label, blocks: [slotSchema]}]`. A block matches when + * every word of the query is found in its label, short label, description or group name. Empty groups are left out. + * A schema from a server that sends no groups lands in one group with no heading. + */ +function groupBlocks(schemas, groups, query = '') { + const words = query.toLowerCase().split(/\s+/).filter(Boolean); + const labelOf = new Map((groups ?? []).map((group) => [group.key, group.label])); + const matches = (slotSchema) => { + const text = [slotSchema.label, slotSchema.short_label, slotSchema.description, labelOf.get(slotSchema.group), slotSchema.type.replaceAll('_', ' ')].filter(Boolean).join(' ').toLowerCase(); + + return words.every((word) => text.includes(word)); + }; + const found = schemas.filter(matches); + + if (!groups || groups.length === 0) { + return found.length > 0 ? [{ key: '', label: '', blocks: found }] : []; + } + + const known = new Set(groups.map((group) => group.key)); + const result = groups.map((group) => ({ ...group, blocks: found.filter((s) => s.group === group.key) })); + // A block whose group the server did not list is still shown, after the others. + const rest = found.filter((s) => !known.has(s.group)); + + if (rest.length > 0) { + result.push({ key: '', label: 'Other', blocks: rest }); + } + + return result.filter((group) => group.blocks.length > 0); +} + // ---- history.js // Undo and redo for the editor. States are immutable slot lists, so each history entry is just a reference. @@ -550,6 +606,46 @@ function renderItems(options, id) { ); } +// ---- icons.js +// Outline icons for the block palette, from Heroicons v2 (24x24, 1.5 stroke; MIT, https://heroicons.com). +// A key is the `icon` of a slot schema; the value is the `d` of each path of the icon. +// To add one: copy its path data from the Heroicons set, in the key's alphabetical place. + +const ICONS = { + 'arrow-path': ['M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99'], + 'chart-bar': ['M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z'], + 'chat-bubble-left-right': ['M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155'], + 'check-circle': ['M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'], + 'clock': ['M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'], + 'code-bracket': ['M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5'], + 'currency-dollar': ['M12 6v12m-3-2.818.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'], + 'cursor-arrow-rays': ['M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672ZM12 2.25V4.5m5.834.166-1.591 1.591M20.25 10.5H18M7.757 14.743l-1.59 1.59M6 10.5H3.75m4.007-4.243-1.59-1.59'], + 'device-phone-mobile': ['M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3'], + 'document-text': ['M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z'], + 'ellipsis-horizontal': ['M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z'], + 'globe-alt': ['M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418'], + 'information-circle': ['m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z'], + 'minus': ['M5 12h14'], + 'photo': ['m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z'], + 'play-circle': ['M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z', 'M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112Z'], + 'question-mark-circle': ['M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z'], + 'receipt-percent': ['m9 14.25 6-6m4.5-3.493V21.75l-3.75-1.5-3.75 1.5-3.75-1.5-3.75 1.5V4.757c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0c1.1.128 1.907 1.077 1.907 2.185ZM9.75 9h.008v.008H9.75V9Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm4.125 4.5h.008v.008h-.008V13.5Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z'], + 'rectangle-group': ['M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 0 1-1.125-1.125v-3.75ZM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 0 1-1.125-1.125v-8.25ZM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 0 1-1.125-1.125v-2.25Z'], + 'rss': ['M12.75 19.5v-.75a7.5 7.5 0 0 0-7.5-7.5H4.5m0-6.75h.75c7.87 0 14.25 6.38 14.25 14.25v.75M6 18.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z'], + 'share': ['M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z'], + 'shopping-bag': ['M15.75 10.5V6a3.75 3.75 0 1 0-7.5 0v4.5m11.356-1.993 1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 0 1-1.12-1.243l1.264-12A1.125 1.125 0 0 1 5.513 7.5h12.974c.576 0 1.059.435 1.119 1.007ZM8.625 10.5a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm7.5 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z'], + 'sparkles': ['M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z'], + 'squares-2x2': ['M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z'], + 'squares-plus': ['M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z'], + 'star': ['M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z'], + 'table-cells': ['M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0 1 12 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M13.125 12h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125M20.625 12c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5M12 14.625v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 14.625c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 1.5v-1.5m0 0c0-.621.504-1.125 1.125-1.125m0 0h7.5'], + 'ticket': ['M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 0 1 0 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 0 1 0-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375Z'], + 'view-columns': ['M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125Z'], +}; + +/** Shown for a schema whose icon key is not in the set above. */ +const FALLBACK_ICON = 'squares-2x2'; + // ---- odden-mail-editor.js // : a drag-and-drop editor for the email slots of getodden/mail. // @@ -560,6 +656,7 @@ function renderItems(options, id) { + const STYLESHEET = null; const RENDER_DELAY_MS = 250; const DRAG_THRESHOLD_PX = 5; @@ -603,6 +700,9 @@ function fetchAdapter(element) { class OddenMailEditor extends HTMLElement { #adapter = null; #schema = new Map(); + #groups = []; + #query = ''; + #tab = 'blocks'; #labels = {}; #slots = []; #base = {}; @@ -733,7 +833,14 @@ class OddenMailEditor extends HTMLElement { refs.status = h('span', { class: 'ome-status', role: 'status' }); refs.live = h('div', { class: 'ome-sr-only', 'aria-live': 'polite', 'aria-atomic': 'true' }); refs.palette = h('div', { class: 'ome-palette' }); + refs.search = h('input', { type: 'search', class: 'ome-input ome-search', placeholder: 'Search blocks', 'aria-label': 'Search blocks', autocomplete: 'off', oninput: (event) => this.#search(event.target.value) }); + refs.noMatch = h('p', { class: 'ome-help', hidden: true }, 'No block matches your search.'); refs.structure = h('ol', { class: 'ome-structure' }); + refs.tabBlocks = h('button', { type: 'button', role: 'tab', id: 'ome-tab-blocks', class: 'ome-tab', 'aria-selected': 'true', 'aria-controls': 'ome-pane-blocks', onclick: () => this.#setTab('blocks') }, 'Blocks'); + refs.tabStructure = h('button', { type: 'button', role: 'tab', id: 'ome-tab-structure', class: 'ome-tab', 'aria-selected': 'false', 'aria-controls': 'ome-pane-structure', tabindex: '-1', onclick: () => this.#setTab('structure') }, 'Structure'); + refs.tabs = h('div', { class: 'ome-tabs', role: 'tablist', 'aria-label': 'Left panel', onkeydown: (event) => this.#tabKey(event) }, refs.tabBlocks, refs.tabStructure); + refs.blocksPane = h('div', { class: 'ome-pane', id: 'ome-pane-blocks', role: 'tabpanel', 'aria-labelledby': 'ome-tab-blocks' }, refs.search, refs.palette, refs.noMatch); + refs.structurePane = h('div', { class: 'ome-pane', id: 'ome-pane-structure', role: 'tabpanel', 'aria-labelledby': 'ome-tab-structure', hidden: true }, refs.structure); refs.panel = h('div', { class: 'ome-panel' }); refs.iframe = h('iframe', { class: 'ome-iframe', title: 'Email preview', sandbox: 'allow-same-origin', scrolling: 'no' }); refs.boxes = h('div', { class: 'ome-boxes', 'aria-hidden': 'true' }); @@ -751,7 +858,7 @@ class OddenMailEditor extends HTMLElement { h( 'div', { class: 'ome-body' }, - h('aside', { class: 'ome-left', 'aria-label': 'Blocks and structure' }, h('h2', { class: 'ome-heading' }, 'Blocks'), refs.palette, h('h2', { class: 'ome-heading' }, 'Structure'), refs.structure), + h('aside', { class: 'ome-left', 'aria-label': 'Blocks and structure' }, refs.tabs, refs.blocksPane, refs.structurePane), h('main', { class: 'ome-canvas' }, refs.error, refs.empty, refs.stage), h('aside', { class: 'ome-right', 'aria-label': 'Block settings' }, refs.panel), ), @@ -801,6 +908,7 @@ class OddenMailEditor extends HTMLElement { this.#schema = new Map(schema.slots.map((slotSchema) => [slotSchema.type, slotSchema])); // A label for every slot type, so a block the editor cannot edit yet still has a readable name. this.#labels = schema.labels ?? {}; + this.#groups = schema.groups ?? []; } catch (error) { this.#showError(error.message); @@ -1144,39 +1252,84 @@ class OddenMailEditor extends HTMLElement { // Palette, structure and property panel #renderPalette() { - const { palette } = this.#refs; + const { palette, noMatch } = this.#refs; + const groups = groupBlocks([...this.#schema.values()], this.#groups, this.#query); clear(palette); + noMatch.hidden = groups.length > 0; - for (const slotSchema of this.#schema.values()) { + for (const group of groups) { palette.append( h( - 'button', - { - type: 'button', - class: 'ome-palette-item', - 'data-type': slotSchema.type, - title: `${slotSchema.label}. Click to add, or drag onto the email.`, - onclick: (event) => { - // A drag ends with a click on the same element: that click must not add a second block. - if (this.#suppressClick) { - this.#suppressClick = false; - event.preventDefault(); - - return; - } - - this.#insertNew(slotSchema.type, null); - }, - onpointerdown: (event) => this.#startDrag(event, { kind: 'new', type: slotSchema.type, label: slotSchema.label }), - }, - h('span', { class: 'ome-palette-glyph', 'aria-hidden': 'true' }, '+'), - slotSchema.label, + 'section', + { class: 'ome-palette-group' }, + group.label ? h('h3', { class: 'ome-group-heading' }, group.label) : null, + h('div', { class: 'ome-tiles' }, group.blocks.map((slotSchema) => this.#renderTile(slotSchema))), ), ); } } + #renderTile(slotSchema) { + const label = slotSchema.short_label ?? slotSchema.label; + const paths = ICONS[slotSchema.icon] ?? ICONS[FALLBACK_ICON]; + + return h( + 'button', + { + type: 'button', + class: 'ome-tile', + 'data-type': slotSchema.type, + 'aria-label': `${slotSchema.label}. Add to the email.`, + title: `${slotSchema.label}${slotSchema.description ? `: ${slotSchema.description}` : ''}\nClick to add, or drag onto the email.`, + onclick: (event) => { + // A drag ends with a click on the same element: that click must not add a second block. + if (this.#suppressClick) { + this.#suppressClick = false; + event.preventDefault(); + + return; + } + + this.#insertNew(slotSchema.type, null); + }, + onpointerdown: (event) => this.#startDrag(event, { kind: 'new', type: slotSchema.type, label: slotSchema.label }), + }, + h('span', { class: 'ome-tile-icon' }, icon(paths)), + h('span', { class: 'ome-tile-label' }, label), + ); + } + + #search(text) { + this.#query = text; + this.#renderPalette(); + } + + #setTab(tab, { focus = false } = {}) { + const { tabBlocks, tabStructure, blocksPane, structurePane } = this.#refs; + + this.#tab = tab; + blocksPane.hidden = tab !== 'blocks'; + structurePane.hidden = tab !== 'structure'; + + for (const [name, button] of [['blocks', tabBlocks], ['structure', tabStructure]]) { + button.setAttribute('aria-selected', String(name === tab)); + button.tabIndex = name === tab ? 0 : -1; + } + + if (focus) { + (tab === 'blocks' ? tabBlocks : tabStructure).focus(); + } + } + + /** Arrow keys move between the two tabs, as the tabs pattern expects. */ + #tabKey(event) { + if (event.key === 'ArrowRight' || event.key === 'ArrowLeft' || event.key === 'Home' || event.key === 'End') { + event.preventDefault(); + this.#setTab(event.key === 'ArrowLeft' || event.key === 'Home' ? 'blocks' : 'structure', { focus: true }); + } + } + #labelOf(slot) { return this.#schema.get(slot.type)?.label ?? this.#labels[slot.type] ?? slot.type.replaceAll('_', ' '); } diff --git a/resources/js/editor/dom.js b/resources/js/editor/dom.js index 22688d4..8b14ac8 100644 --- a/resources/js/editor/dom.js +++ b/resources/js/editor/dom.js @@ -39,3 +39,28 @@ export function clear(element) { element.removeChild(element.firstChild); } } + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +/** A decorative 24x24 outline icon from path data (the `d` of each path). */ +export function icon(paths) { + const svg = document.createElementNS(SVG_NS, 'svg'); + + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '1.5'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('focusable', 'false'); + + for (const d of paths) { + const path = document.createElementNS(SVG_NS, 'path'); + + path.setAttribute('d', d); + svg.append(path); + } + + return svg; +} diff --git a/resources/js/editor/editor.css b/resources/js/editor/editor.css index c11d897..f026307 100644 --- a/resources/js/editor/editor.css +++ b/resources/js/editor/editor.css @@ -99,7 +99,6 @@ odden-mail-editor { .ome-button, .ome-icon-button, -.ome-palette-item, .ome-structure-select, .ome-handle { background: var(--ome-surface); @@ -136,7 +135,6 @@ odden-mail-editor { .ome-button:hover:not(:disabled), .ome-icon-button:hover:not(:disabled), -.ome-palette-item:hover, .ome-structure-select:hover { background: #f8fafc; border-color: #cbd5e1; @@ -154,27 +152,99 @@ odden-mail-editor { /* Palette */ -.ome-palette { +.ome-tabs { + background: var(--ome-canvas); + border-radius: 8px; + display: grid; + gap: 2px; + grid-template-columns: 1fr 1fr; + margin-bottom: 12px; + padding: 2px; +} + +.ome-tab { + background: transparent; + border: 0; + border-radius: 6px; + color: var(--ome-muted); + cursor: pointer; + font: inherit; + font-weight: 600; + min-height: 30px; +} + +.ome-tab[aria-selected='true'] { + background: var(--ome-surface); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12); + color: var(--ome-ink); +} + +.ome-pane[hidden] { + display: none; +} + +.ome-search { + margin-bottom: 12px; + width: 100%; +} + +.ome-group-heading { + color: var(--ome-muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + margin: 14px 0 6px; + text-transform: uppercase; +} + +.ome-palette-group:first-child .ome-group-heading { + margin-top: 0; +} + +.ome-tiles { display: grid; gap: 6px; - grid-template-columns: 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); } -.ome-palette-item { +.ome-tile { align-items: center; + background: var(--ome-surface); + border: 1px solid var(--ome-line); + border-radius: 8px; + color: var(--ome-ink); cursor: grab; display: flex; - gap: 8px; - min-height: 34px; - padding: 4px 10px; - text-align: left; + flex-direction: column; + font: inherit; + font-size: 12px; + gap: 6px; + justify-content: center; + min-height: 72px; + padding: 10px 6px; + text-align: center; touch-action: none; user-select: none; } -.ome-palette-glyph { +.ome-tile:hover { + background: var(--ome-accent-soft); + border-color: var(--ome-accent); +} + +.ome-tile-icon { color: var(--ome-accent); - font-weight: 700; + display: inline-flex; +} + +.ome-tile-icon svg { + height: 24px; + width: 24px; +} + +.ome-tile-label { + line-height: 1.25; + overflow-wrap: anywhere; } /* Structure list */ diff --git a/resources/js/editor/icons.js b/resources/js/editor/icons.js new file mode 100644 index 0000000..2efe210 --- /dev/null +++ b/resources/js/editor/icons.js @@ -0,0 +1,38 @@ +// Outline icons for the block palette, from Heroicons v2 (24x24, 1.5 stroke; MIT, https://heroicons.com). +// A key is the `icon` of a slot schema; the value is the `d` of each path of the icon. +// To add one: copy its path data from the Heroicons set, in the key's alphabetical place. + +export const ICONS = { + 'arrow-path': ['M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99'], + 'chart-bar': ['M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z'], + 'chat-bubble-left-right': ['M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155'], + 'check-circle': ['M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'], + 'clock': ['M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'], + 'code-bracket': ['M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5'], + 'currency-dollar': ['M12 6v12m-3-2.818.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'], + 'cursor-arrow-rays': ['M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672ZM12 2.25V4.5m5.834.166-1.591 1.591M20.25 10.5H18M7.757 14.743l-1.59 1.59M6 10.5H3.75m4.007-4.243-1.59-1.59'], + 'device-phone-mobile': ['M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3'], + 'document-text': ['M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z'], + 'ellipsis-horizontal': ['M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z'], + 'globe-alt': ['M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418'], + 'information-circle': ['m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z'], + 'minus': ['M5 12h14'], + 'photo': ['m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z'], + 'play-circle': ['M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z', 'M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112Z'], + 'question-mark-circle': ['M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z'], + 'receipt-percent': ['m9 14.25 6-6m4.5-3.493V21.75l-3.75-1.5-3.75 1.5-3.75-1.5-3.75 1.5V4.757c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0c1.1.128 1.907 1.077 1.907 2.185ZM9.75 9h.008v.008H9.75V9Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm4.125 4.5h.008v.008h-.008V13.5Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z'], + 'rectangle-group': ['M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 0 1-1.125-1.125v-3.75ZM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 0 1-1.125-1.125v-8.25ZM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 0 1-1.125-1.125v-2.25Z'], + 'rss': ['M12.75 19.5v-.75a7.5 7.5 0 0 0-7.5-7.5H4.5m0-6.75h.75c7.87 0 14.25 6.38 14.25 14.25v.75M6 18.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z'], + 'share': ['M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z'], + 'shopping-bag': ['M15.75 10.5V6a3.75 3.75 0 1 0-7.5 0v4.5m11.356-1.993 1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 0 1-1.12-1.243l1.264-12A1.125 1.125 0 0 1 5.513 7.5h12.974c.576 0 1.059.435 1.119 1.007ZM8.625 10.5a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm7.5 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z'], + 'sparkles': ['M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z'], + 'squares-2x2': ['M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z'], + 'squares-plus': ['M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z'], + 'star': ['M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z'], + 'table-cells': ['M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0 1 12 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M13.125 12h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125M20.625 12c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5M12 14.625v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 14.625c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 1.5v-1.5m0 0c0-.621.504-1.125 1.125-1.125m0 0h7.5'], + 'ticket': ['M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 0 1 0 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 0 1 0-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375Z'], + 'view-columns': ['M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125Z'], +}; + +/** Shown for a schema whose icon key is not in the set above. */ +export const FALLBACK_ICON = 'squares-2x2'; diff --git a/resources/js/editor/model.js b/resources/js/editor/model.js index a0ae1f6..48784ea 100644 --- a/resources/js/editor/model.js +++ b/resources/js/editor/model.js @@ -220,3 +220,34 @@ export function dropLineY(rects, index) { return index >= rects.length ? rects[rects.length - 1].bottom : rects[index].top; } + +/** + * The blocks of the palette for a search text, in groups: `[{key, label, blocks: [slotSchema]}]`. A block matches when + * every word of the query is found in its label, short label, description or group name. Empty groups are left out. + * A schema from a server that sends no groups lands in one group with no heading. + */ +export function groupBlocks(schemas, groups, query = '') { + const words = query.toLowerCase().split(/\s+/).filter(Boolean); + const labelOf = new Map((groups ?? []).map((group) => [group.key, group.label])); + const matches = (slotSchema) => { + const text = [slotSchema.label, slotSchema.short_label, slotSchema.description, labelOf.get(slotSchema.group), slotSchema.type.replaceAll('_', ' ')].filter(Boolean).join(' ').toLowerCase(); + + return words.every((word) => text.includes(word)); + }; + const found = schemas.filter(matches); + + if (!groups || groups.length === 0) { + return found.length > 0 ? [{ key: '', label: '', blocks: found }] : []; + } + + const known = new Set(groups.map((group) => group.key)); + const result = groups.map((group) => ({ ...group, blocks: found.filter((s) => s.group === group.key) })); + // A block whose group the server did not list is still shown, after the others. + const rest = found.filter((s) => !known.has(s.group)); + + if (rest.length > 0) { + result.push({ key: '', label: 'Other', blocks: rest }); + } + + return result.filter((group) => group.blocks.length > 0); +} diff --git a/resources/js/editor/odden-mail-editor.js b/resources/js/editor/odden-mail-editor.js index bbb2c41..35ec4ea 100644 --- a/resources/js/editor/odden-mail-editor.js +++ b/resources/js/editor/odden-mail-editor.js @@ -3,8 +3,9 @@ // It edits the slot document as data and never edits HTML: the server renders the preview with the same compiler that // builds the email, so what you see is what is sent. See the README for the attributes, properties and events. -import { clear, h } from './dom.js'; +import { clear, h, icon } from './dom.js'; import { renderField } from './fields.js'; +import { FALLBACK_ICON, ICONS } from './icons.js'; import { History } from './history.js'; import { addItem, @@ -12,6 +13,7 @@ import { dropLineY, duplicateSlot, fromDocument, + groupBlocks, indexOfSlot, insertAt, insertionIndex, @@ -67,6 +69,9 @@ function fetchAdapter(element) { export class OddenMailEditor extends HTMLElement { #adapter = null; #schema = new Map(); + #groups = []; + #query = ''; + #tab = 'blocks'; #labels = {}; #slots = []; #base = {}; @@ -197,7 +202,14 @@ export class OddenMailEditor extends HTMLElement { refs.status = h('span', { class: 'ome-status', role: 'status' }); refs.live = h('div', { class: 'ome-sr-only', 'aria-live': 'polite', 'aria-atomic': 'true' }); refs.palette = h('div', { class: 'ome-palette' }); + refs.search = h('input', { type: 'search', class: 'ome-input ome-search', placeholder: 'Search blocks', 'aria-label': 'Search blocks', autocomplete: 'off', oninput: (event) => this.#search(event.target.value) }); + refs.noMatch = h('p', { class: 'ome-help', hidden: true }, 'No block matches your search.'); refs.structure = h('ol', { class: 'ome-structure' }); + refs.tabBlocks = h('button', { type: 'button', role: 'tab', id: 'ome-tab-blocks', class: 'ome-tab', 'aria-selected': 'true', 'aria-controls': 'ome-pane-blocks', onclick: () => this.#setTab('blocks') }, 'Blocks'); + refs.tabStructure = h('button', { type: 'button', role: 'tab', id: 'ome-tab-structure', class: 'ome-tab', 'aria-selected': 'false', 'aria-controls': 'ome-pane-structure', tabindex: '-1', onclick: () => this.#setTab('structure') }, 'Structure'); + refs.tabs = h('div', { class: 'ome-tabs', role: 'tablist', 'aria-label': 'Left panel', onkeydown: (event) => this.#tabKey(event) }, refs.tabBlocks, refs.tabStructure); + refs.blocksPane = h('div', { class: 'ome-pane', id: 'ome-pane-blocks', role: 'tabpanel', 'aria-labelledby': 'ome-tab-blocks' }, refs.search, refs.palette, refs.noMatch); + refs.structurePane = h('div', { class: 'ome-pane', id: 'ome-pane-structure', role: 'tabpanel', 'aria-labelledby': 'ome-tab-structure', hidden: true }, refs.structure); refs.panel = h('div', { class: 'ome-panel' }); refs.iframe = h('iframe', { class: 'ome-iframe', title: 'Email preview', sandbox: 'allow-same-origin', scrolling: 'no' }); refs.boxes = h('div', { class: 'ome-boxes', 'aria-hidden': 'true' }); @@ -215,7 +227,7 @@ export class OddenMailEditor extends HTMLElement { h( 'div', { class: 'ome-body' }, - h('aside', { class: 'ome-left', 'aria-label': 'Blocks and structure' }, h('h2', { class: 'ome-heading' }, 'Blocks'), refs.palette, h('h2', { class: 'ome-heading' }, 'Structure'), refs.structure), + h('aside', { class: 'ome-left', 'aria-label': 'Blocks and structure' }, refs.tabs, refs.blocksPane, refs.structurePane), h('main', { class: 'ome-canvas' }, refs.error, refs.empty, refs.stage), h('aside', { class: 'ome-right', 'aria-label': 'Block settings' }, refs.panel), ), @@ -265,6 +277,7 @@ export class OddenMailEditor extends HTMLElement { this.#schema = new Map(schema.slots.map((slotSchema) => [slotSchema.type, slotSchema])); // A label for every slot type, so a block the editor cannot edit yet still has a readable name. this.#labels = schema.labels ?? {}; + this.#groups = schema.groups ?? []; } catch (error) { this.#showError(error.message); @@ -608,39 +621,84 @@ export class OddenMailEditor extends HTMLElement { // Palette, structure and property panel #renderPalette() { - const { palette } = this.#refs; + const { palette, noMatch } = this.#refs; + const groups = groupBlocks([...this.#schema.values()], this.#groups, this.#query); clear(palette); + noMatch.hidden = groups.length > 0; - for (const slotSchema of this.#schema.values()) { + for (const group of groups) { palette.append( h( - 'button', - { - type: 'button', - class: 'ome-palette-item', - 'data-type': slotSchema.type, - title: `${slotSchema.label}. Click to add, or drag onto the email.`, - onclick: (event) => { - // A drag ends with a click on the same element: that click must not add a second block. - if (this.#suppressClick) { - this.#suppressClick = false; - event.preventDefault(); - - return; - } - - this.#insertNew(slotSchema.type, null); - }, - onpointerdown: (event) => this.#startDrag(event, { kind: 'new', type: slotSchema.type, label: slotSchema.label }), - }, - h('span', { class: 'ome-palette-glyph', 'aria-hidden': 'true' }, '+'), - slotSchema.label, + 'section', + { class: 'ome-palette-group' }, + group.label ? h('h3', { class: 'ome-group-heading' }, group.label) : null, + h('div', { class: 'ome-tiles' }, group.blocks.map((slotSchema) => this.#renderTile(slotSchema))), ), ); } } + #renderTile(slotSchema) { + const label = slotSchema.short_label ?? slotSchema.label; + const paths = ICONS[slotSchema.icon] ?? ICONS[FALLBACK_ICON]; + + return h( + 'button', + { + type: 'button', + class: 'ome-tile', + 'data-type': slotSchema.type, + 'aria-label': `${slotSchema.label}. Add to the email.`, + title: `${slotSchema.label}${slotSchema.description ? `: ${slotSchema.description}` : ''}\nClick to add, or drag onto the email.`, + onclick: (event) => { + // A drag ends with a click on the same element: that click must not add a second block. + if (this.#suppressClick) { + this.#suppressClick = false; + event.preventDefault(); + + return; + } + + this.#insertNew(slotSchema.type, null); + }, + onpointerdown: (event) => this.#startDrag(event, { kind: 'new', type: slotSchema.type, label: slotSchema.label }), + }, + h('span', { class: 'ome-tile-icon' }, icon(paths)), + h('span', { class: 'ome-tile-label' }, label), + ); + } + + #search(text) { + this.#query = text; + this.#renderPalette(); + } + + #setTab(tab, { focus = false } = {}) { + const { tabBlocks, tabStructure, blocksPane, structurePane } = this.#refs; + + this.#tab = tab; + blocksPane.hidden = tab !== 'blocks'; + structurePane.hidden = tab !== 'structure'; + + for (const [name, button] of [['blocks', tabBlocks], ['structure', tabStructure]]) { + button.setAttribute('aria-selected', String(name === tab)); + button.tabIndex = name === tab ? 0 : -1; + } + + if (focus) { + (tab === 'blocks' ? tabBlocks : tabStructure).focus(); + } + } + + /** Arrow keys move between the two tabs, as the tabs pattern expects. */ + #tabKey(event) { + if (event.key === 'ArrowRight' || event.key === 'ArrowLeft' || event.key === 'Home' || event.key === 'End') { + event.preventDefault(); + this.#setTab(event.key === 'ArrowLeft' || event.key === 'Home' ? 'blocks' : 'structure', { focus: true }); + } + } + #labelOf(slot) { return this.#schema.get(slot.type)?.label ?? this.#labels[slot.type] ?? slot.type.replaceAll('_', ' '); } diff --git a/scripts/build-editor.mjs b/scripts/build-editor.mjs index 5bf615f..f5d8186 100644 --- a/scripts/build-editor.mjs +++ b/scripts/build-editor.mjs @@ -13,7 +13,7 @@ const source = join(root, 'resources/js/editor'); const dist = join(root, 'resources/dist'); // Dependency order: a module comes after the modules it imports. -const MODULES = ['dom.js', 'model.js', 'history.js', 'fields.js', 'odden-mail-editor.js', 'filament-field.js']; +const MODULES = ['dom.js', 'model.js', 'history.js', 'fields.js', 'icons.js', 'odden-mail-editor.js', 'filament-field.js']; /** Remove `import ... from '...';` (also over several lines) and the `export` keywords, keeping `export default`. */ export function strip(code, file) { diff --git a/src/Enums/SlotGroup.php b/src/Enums/SlotGroup.php new file mode 100644 index 0000000..7acb3dd --- /dev/null +++ b/src/Enums/SlotGroup.php @@ -0,0 +1,30 @@ + 'Structure', + self::Content => 'Content', + self::Layout => 'Columns & layout', + self::Commerce => 'Commerce', + self::Engagement => 'Engagement', + self::Data => 'Data & feeds', + }; + } +} diff --git a/src/Enums/SlotType.php b/src/Enums/SlotType.php index bf5ca8d..ce35d52 100644 --- a/src/Enums/SlotType.php +++ b/src/Enums/SlotType.php @@ -71,6 +71,77 @@ public function label(): string }; } + /** A name short enough for a tile in an editor's block palette. */ + public function shortLabel(): string + { + return match ($this) { + self::Header => 'Header', + self::Hero => 'Hero', + self::BodyText => 'Text', + self::Button => 'Button', + self::TwoColumn => '2 columns', + self::Features => 'Features', + self::Testimonial => 'Quote', + self::StatBox => 'Key metrics', + self::Divider => 'Divider', + self::SocialLinks => 'Social links', + self::Footer => 'Footer', + self::Html => 'Custom HTML', + self::ImageBanner => 'Image', + self::VideoCard => 'Video', + self::PricingGrid => 'Pricing', + self::RatingBar => 'Rating', + self::CountdownTimer => 'Countdown', + self::Accordion => 'FAQ', + self::DynamicFeed => 'Product feed', + self::OrderReceipt => 'Receipt', + self::RssFeed => 'Articles', + self::ThreeColumn => '3 columns', + self::FourColumn => 'Logo wall', + self::AsymmetricColumns => 'Sidebar split', + self::ProductCatalog => 'Catalog', + self::CouponCode => 'Coupon', + self::AppBadges => 'App badges', + self::DataTable => 'Table', + self::LabeledDivider => 'Section label', + }; + } + + public function group(): SlotGroup + { + return match ($this) { + self::Header => SlotGroup::Structure, + self::Footer => SlotGroup::Structure, + self::Divider => SlotGroup::Structure, + self::LabeledDivider => SlotGroup::Structure, + self::Hero => SlotGroup::Content, + self::BodyText => SlotGroup::Content, + self::Button => SlotGroup::Content, + self::ImageBanner => SlotGroup::Content, + self::Features => SlotGroup::Content, + self::Testimonial => SlotGroup::Content, + self::Accordion => SlotGroup::Content, + self::VideoCard => SlotGroup::Content, + self::Html => SlotGroup::Content, + self::TwoColumn => SlotGroup::Layout, + self::ThreeColumn => SlotGroup::Layout, + self::FourColumn => SlotGroup::Layout, + self::AsymmetricColumns => SlotGroup::Layout, + self::PricingGrid => SlotGroup::Commerce, + self::ProductCatalog => SlotGroup::Commerce, + self::DynamicFeed => SlotGroup::Commerce, + self::OrderReceipt => SlotGroup::Commerce, + self::CouponCode => SlotGroup::Commerce, + self::CountdownTimer => SlotGroup::Engagement, + self::RatingBar => SlotGroup::Engagement, + self::SocialLinks => SlotGroup::Engagement, + self::AppBadges => SlotGroup::Engagement, + self::StatBox => SlotGroup::Data, + self::DataTable => SlotGroup::Data, + self::RssFeed => SlotGroup::Data, + }; + } + public function description(): string { return match ($this) { diff --git a/src/Schema/SlotSchema.php b/src/Schema/SlotSchema.php index 6ee747b..18073aa 100644 --- a/src/Schema/SlotSchema.php +++ b/src/Schema/SlotSchema.php @@ -70,6 +70,9 @@ public function toArray(): array return [ 'type' => $this->type->value, 'label' => $this->label(), + 'short_label' => $this->type->shortLabel(), + 'description' => $this->type->description(), + 'group' => $this->type->group()->value, 'icon' => $this->icon, 'columns' => $this->columns, 'supports_visibility' => $this->supportsVisibility, diff --git a/src/Schema/SlotSchemaRegistry.php b/src/Schema/SlotSchemaRegistry.php index 7ce40a1..8c7e041 100644 --- a/src/Schema/SlotSchemaRegistry.php +++ b/src/Schema/SlotSchemaRegistry.php @@ -5,6 +5,7 @@ namespace Odden\MailBuilder\Schema; use Odden\MailBuilder\Enums\ButtonStyle; +use Odden\MailBuilder\Enums\SlotGroup; use Odden\MailBuilder\Enums\SlotType; /** @@ -16,7 +17,7 @@ final class SlotSchemaRegistry { /** Bumped when the shape of the exported schema changes. */ - public const VERSION = 2; + public const VERSION = 3; /** * The slot types that have a schema, in the order an editor lists them. @@ -111,7 +112,7 @@ public static function all(): array * * `labels` names every slot type, also the ones with no schema yet, so an editor can show a block it cannot edit. * - * @return array{version: int, slots: array>, labels: array} + * @return array{version: int, groups: array, slots: array>, labels: array} */ public static function toArray(): array { @@ -123,6 +124,7 @@ public static function toArray(): array return [ 'version' => self::VERSION, + 'groups' => array_map(fn (SlotGroup $group): array => ['key' => $group->value, 'label' => $group->label()], SlotGroup::cases()), 'slots' => array_map(fn (SlotSchema $schema): array => $schema->toArray(), self::all()), 'labels' => $labels, ]; @@ -291,7 +293,7 @@ private static function twoColumn(): SlotSchema private static function threeColumn(): SlotSchema { - return new SlotSchema(SlotType::ThreeColumn, 'view-columns', [ + return new SlotSchema(SlotType::ThreeColumn, 'squares-plus', [ new SlotField('heading', FieldType::Text, 'Section Heading', placeholder: 'e.g. Featured Highlights', fullWidth: true), new SlotField('subtitle', FieldType::Text, 'Subtitle', placeholder: 'e.g. Explore our newest feature releases', fullWidth: true), new SlotField('columns', FieldType::Items, 'Three Columns', fullWidth: true, defaultItems: 3, itemColumns: 2, items: [ @@ -462,7 +464,7 @@ private static function productCatalog(): SlotSchema private static function dynamicFeed(): SlotSchema { - return new SlotSchema(SlotType::DynamicFeed, 'sparkles', [ + return new SlotSchema(SlotType::DynamicFeed, 'arrow-path', [ new SlotField('heading', FieldType::Text, 'Section Heading', default: 'Recommended For You', fullWidth: true), new SlotField('subtitle', FieldType::Text, 'Subtitle', placeholder: 'Curated recommendations based on your preferences', fullWidth: true), new SlotField('items', FieldType::Items, 'Dynamic Products / Content Items', fullWidth: true, defaultItems: 2, itemColumns: 2, items: [ @@ -505,7 +507,7 @@ private static function accordion(): SlotSchema private static function orderReceipt(): SlotSchema { - return new SlotSchema(SlotType::OrderReceipt, 'shopping-bag', [ + return new SlotSchema(SlotType::OrderReceipt, 'receipt-percent', [ new SlotField('order_number', FieldType::Text, 'Order / Reference #', required: true, default: '#1001'), new SlotField('order_date', FieldType::Text, 'Order Date', placeholder: 'e.g. October 24, 2026'), new SlotField('currency', FieldType::Text, 'Currency Symbol', default: '$'), @@ -595,7 +597,7 @@ private static function appBadges(): SlotSchema private static function labeledDivider(): SlotSchema { - return new SlotSchema(SlotType::LabeledDivider, 'minus', [ + return new SlotSchema(SlotType::LabeledDivider, 'ellipsis-horizontal', [ new SlotField('label', FieldType::Text, 'Divider Center Label / Badge', required: true, default: 'OR'), new SlotField('badge_bg', FieldType::Color, 'Badge Background Color', default: '#f1f5f9'), new SlotField('text_color', FieldType::Color, 'Badge Text Color', default: '#64748b'), diff --git a/tests/Unit/SlotSchemaTest.php b/tests/Unit/SlotSchemaTest.php index 891ab4d..964e104 100644 --- a/tests/Unit/SlotSchemaTest.php +++ b/tests/Unit/SlotSchemaTest.php @@ -6,6 +6,7 @@ use Odden\MailBuilder\Compilers\EmailSlotCompiler; use Odden\MailBuilder\Data\EmailSlot; +use Odden\MailBuilder\Enums\SlotGroup; use Odden\MailBuilder\Enums\SlotType; use Odden\MailBuilder\Schema\FieldType; use Odden\MailBuilder\Schema\SlotField; @@ -188,4 +189,33 @@ public function test_advanced_fields_are_marked_and_kept_out_of_the_basic_form() $this->assertContains('hero_image', array_map(fn (SlotField $f): string => $f->key, $hero->fields)); $this->assertNotContains('hero_image', array_map(fn (SlotField $f): string => $f->key, $hero->basicFields())); } + + public function test_every_type_is_in_a_listed_group_with_a_short_label_for_the_palette(): void + { + $export = SlotSchemaRegistry::toArray(); + $groupKeys = array_column($export['groups'], 'key'); + + $this->assertSame(array_map(fn (SlotGroup $group): string => $group->value, SlotGroup::cases()), $groupKeys); + + foreach ($export['slots'] as $slot) { + $this->assertContains($slot['group'], $groupKeys, $slot['type']); + $this->assertNotSame('', trim((string) $slot['short_label']), $slot['type']); + $this->assertLessThanOrEqual(14, mb_strlen((string) $slot['short_label']), "{$slot['type']} has a short label too long for a tile."); + } + + foreach ($groupKeys as $key) { + $this->assertNotEmpty(array_filter($export['slots'], fn (array $slot): bool => $slot['group'] === $key), "The group [{$key}] is empty."); + } + } + + public function test_every_schema_icon_has_its_path_data_in_the_editor(): void + { + $icons = file_get_contents(dirname(__DIR__, 2).'/resources/js/editor/icons.js'); + + $this->assertIsString($icons); + + foreach (SlotSchemaRegistry::all() as $schema) { + $this->assertStringContainsString("'{$schema->icon}': [", $icons, "The editor has no icon for [{$schema->icon}] ({$schema->type->value})."); + } + } } diff --git a/tests/js/model.test.mjs b/tests/js/model.test.mjs index 3bf7742..3da169c 100644 --- a/tests/js/model.test.mjs +++ b/tests/js/model.test.mjs @@ -149,3 +149,42 @@ test('the drop line sits on the slot boundary', () => { assert.equal(dropLineY(rects, 2), 150); assert.equal(dropLineY([], 0), 0); }); + +test('the palette groups blocks in the server order and drops empty groups', async () => { + const { groupBlocks } = await import('../../resources/js/editor/model.js'); + const groups = [{ key: 'content', label: 'Content' }, { key: 'layout', label: 'Columns & layout' }, { key: 'data', label: 'Data' }]; + const schemas = [ + { type: 'two_column', label: '2-Column Card Grid', short_label: '2 columns', description: 'Side-by-side layout', group: 'layout' }, + { type: 'hero', label: 'Hero Headline & Banner', short_label: 'Hero', description: 'Bold banner', group: 'content' }, + { type: 'body_text', label: 'Text & Content', short_label: 'Text', description: 'Editorial text', group: 'content' }, + ]; + + const all = groupBlocks(schemas, groups); + + assert.deepEqual(all.map((g) => g.key), ['content', 'layout']); + assert.deepEqual(all[0].blocks.map((b) => b.type), ['hero', 'body_text']); +}); + +test('a search matches every word against the label, short label, description and group', async () => { + const { groupBlocks } = await import('../../resources/js/editor/model.js'); + const groups = [{ key: 'content', label: 'Content' }, { key: 'layout', label: 'Columns & layout' }]; + const schemas = [ + { type: 'two_column', label: '2-Column Card Grid', short_label: '2 columns', description: 'Side-by-side layout', group: 'layout' }, + { type: 'hero', label: 'Hero Headline & Banner', short_label: 'Hero', description: 'Bold banner', group: 'content' }, + ]; + const types = (query) => groupBlocks(schemas, groups, query).flatMap((g) => g.blocks.map((b) => b.type)); + + assert.deepEqual(types('hero'), ['hero']); + assert.deepEqual(types('SIDE by'), ['two_column']); + assert.deepEqual(types('columns layout'), ['two_column']); + assert.deepEqual(types('banner headline'), ['hero']); + assert.deepEqual(types('nothing like this'), []); +}); + +test('a schema with no groups is one group without a heading, and an unlisted group is shown last', async () => { + const { groupBlocks } = await import('../../resources/js/editor/model.js'); + const schemas = [{ type: 'hero', label: 'Hero', group: 'new_group' }]; + + assert.deepEqual(groupBlocks(schemas, undefined).map((g) => [g.key, g.label, g.blocks.length]), [['', '', 1]]); + assert.deepEqual(groupBlocks(schemas, [{ key: 'content', label: 'Content' }]).map((g) => [g.key, g.label]), [['', 'Other']]); +}); diff --git a/workbench/routes/web.php b/workbench/routes/web.php index 0d91a70..f38e774 100644 --- a/workbench/routes/web.php +++ b/workbench/routes/web.php @@ -18,7 +18,7 @@ // Serve the editor's files straight from resources, so there is nothing to publish while developing. Route::get('/editor/{file}', function (string $file) { - abort_unless(in_array($file, ['odden-mail-editor.js', 'model.js', 'history.js', 'fields.js', 'dom.js', 'editor.css'], true), 404); + abort_unless(in_array($file, ['odden-mail-editor.js', 'model.js', 'history.js', 'fields.js', 'icons.js', 'dom.js', 'editor.css'], true), 404); return response(File::get(dirname(__DIR__, 2).'/resources/js/editor/'.$file), 200, [ 'Content-Type' => str_ends_with($file, '.css') ? 'text/css' : 'text/javascript',