diff --git a/blocks/canvas/canvas.css b/blocks/canvas/canvas.css new file mode 100644 index 00000000..d7660010 --- /dev/null +++ b/blocks/canvas/canvas.css @@ -0,0 +1,57 @@ +:root { + --ew-canvas-header-height: 48px; +} + +html:has(aside.panel[data-position="before"]:not([hidden])) ew-canvas-header::part(toggle-before) { + display: none; +} + +html:has(aside.panel[data-position="after"]:not([hidden])) ew-canvas-header::part(toggle-after) { + display: none; +} + +.fragment-content:has(nx-chat) { + height: 100%; + + & .section { + height: 100%; + } + + & .block-content { + height: 100%; + } +} + +nx-chat { + height: 100%; +} + +/* Layout / content / split: one visible editor set; split uses WYSIWYG | gutter | doc; inactive uses [hidden] */ +.nx-canvas-editor-mount { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + gap: 0; + height: calc(100vh - var(--ew-canvas-header-height) - var(--s2-nav-height)); +} + +ew-editor-doc { + display: block; + flex: 1; + min-height: 0; + max-height: calc(100vh - var(--ew-canvas-header-height) - var(--s2-nav-height)); + overflow-y: auto; +} + +ew-editor-wysiwyg, +ew-editor-doc { + contain: layout; + overflow: hidden; +} + +ew-editor-doc[hidden], +ew-editor-wysiwyg[hidden] { + display: none !important; +} + diff --git a/blocks/canvas/canvas.js b/blocks/canvas/canvas.js new file mode 100644 index 00000000..60c645eb --- /dev/null +++ b/blocks/canvas/canvas.js @@ -0,0 +1,232 @@ +import { getNx } from '../../scripts/utils.js'; +import { editorSelectChange } from './editor-utils/editor-utils.js'; +import './ew-canvas-header/ew-canvas-header.js'; +import './ew-editor-doc/ew-editor-doc.js'; +import './ew-editor-wysiwyg/ew-editor-wysiwyg.js'; +import { + syncEditorSplitLayout, + finalizeSplitEditorMountOrder, + installEditorSplitDrag, + removeSplitGutter, +} from './ew-editor-split/ew-editor-split.js'; + +const { loadStyle, hashChange } = await import(`${getNx()}/utils/utils.js`); +const { getPanelStore, openPanel } = await import(`${getNx()}/utils/panel.js`); + +const style = await loadStyle(import.meta.url); +document.adoptedStyleSheets = [...document.adoptedStyleSheets, style]; + +function buildCanvasDocPath(state) { + const { org, site, path } = state || {}; + if (!org || !site || !path) return null; + return `${org}/${site}/${path}`; +} + +const CANVAS_EDITOR_VIEW_KEY = 'nx-canvas-editor-view'; + +function normalizeCanvasEditorView(view) { + if (view === 'content') return 'content'; + if (view === 'split') return 'split'; + return 'layout'; +} + +function notifyCanvasEditorActive(mountRoot, view) { + const v = normalizeCanvasEditorView(view); + mountRoot.dispatchEvent(new CustomEvent('nx-canvas-editor-active', { + bubbles: false, + detail: { view: v }, + })); +} + +function readPersistedCanvasEditorView() { + try { + return normalizeCanvasEditorView(sessionStorage.getItem(CANVAS_EDITOR_VIEW_KEY)); + } catch { + return 'layout'; + } +} + +function persistCanvasEditorView(view) { + try { + sessionStorage.setItem(CANVAS_EDITOR_VIEW_KEY, normalizeCanvasEditorView(view)); + } catch { + /* ignore if browser disallows session storage */ + } +} + +function canvasEditorMountRoot(block) { + return block.querySelector('.default-content') || block; +} + +function canvasHeaderApplyTarget(block) { + return block.querySelector('.nx-canvas-editor-mount') + || block.querySelector('.default-content') + || block; +} + +function removeCanvasEditors(mountRoot) { + removeSplitGutter(mountRoot); + mountRoot.querySelector('ew-editor-doc')?.remove(); + mountRoot.querySelector('ew-editor-wysiwyg')?.remove(); +} + +function ensureNxEditorDoc(mountRoot) { + let el = mountRoot.querySelector('ew-editor-doc'); + if (!el) { + el = document.createElement('ew-editor-doc'); + mountRoot.append(el); + } + return el; +} + +function ensureNxEditorWysiwyg(mountRoot) { + let frame = mountRoot.querySelector('ew-editor-wysiwyg'); + if (!frame) { + frame = document.createElement('ew-editor-wysiwyg'); + mountRoot.append(frame); + } + return frame; +} + +function editorCtxFromHashState(state, fullPath) { + return { org: state.org, repo: state.site, path: fullPath }; +} + +function syncCanvasEditorsToHash({ mountRoot, header, state }) { + header.undoAvailable = false; + header.redoAvailable = false; + const fullPath = buildCanvasDocPath(state); + if (!fullPath) { + removeCanvasEditors(mountRoot); + return; + } + const ctx = editorCtxFromHashState(state, fullPath); + ensureNxEditorWysiwyg(mountRoot).ctx = ctx; + ensureNxEditorDoc(mountRoot).ctx = ctx; + finalizeSplitEditorMountOrder(mountRoot); + notifyCanvasEditorActive(mountRoot, header.editorView); + syncEditorSplitLayout({ mountRoot, view: header.editorView }); +} + +async function syncToolPanelViews(toolPanel, { org, site }) { + const key = org && site ? `${org}/${site}` : null; + if (key === toolPanel.dataset.extKey) return; + toolPanel.dataset.extKey = key ?? ''; + + if (!key) { + toolPanel.views = []; + return; + } + + const { getCanvasToolPanelViews } = await import('./ew-panel-extensions/helpers.js'); + const views = await getCanvasToolPanelViews({ org, site }); + if (toolPanel.dataset.extKey !== key) return; + toolPanel.views = views; +} + +const CANVAS_PANELS = { + before: { + width: '400px', + getContent: async () => { + await import(`${getNx()}/blocks/chat/chat.js`); + return document.createElement('nx-chat'); + }, + }, + after: { + width: '400px', + getContent: async () => { + await import('./ew-tool-panel/tool-panel.js'); + return document.createElement('ew-tool-panel'); + }, + }, +}; + +function hashState() { + const [org, site] = window.location.hash.slice(2).split('/'); + return { org: org || undefined, site: site || undefined }; +} + +async function openCanvasPanel(position, { preferredViewId } = {}) { + const config = CANVAS_PANELS[position]; + if (!config) return; + const store = getPanelStore(); + const width = store[position]?.width ?? config.width; + const aside = await openPanel({ position, width, getContent: config.getContent }); + if (position === 'after') { + const toolPanel = aside?.querySelector('ew-tool-panel'); + if (toolPanel) { + await syncToolPanelViews(toolPanel, hashState()); + await toolPanel.updateComplete; + if (preferredViewId && toolPanel.views?.some((v) => v.id === preferredViewId)) { + await toolPanel.showView(preferredViewId); + } + } + } +} + +function installCanvasHeader(block) { + const header = document.createElement('ew-canvas-header'); + header.editorView = readPersistedCanvasEditorView(); + header.addEventListener('nx-canvas-open-panel', (e) => { + openCanvasPanel(e.detail.position, { preferredViewId: e.detail.viewId }); + }); + header.addEventListener('nx-canvas-editor-view', (e) => { + const view = normalizeCanvasEditorView(e.detail?.view); + persistCanvasEditorView(view); + const applyTarget = canvasHeaderApplyTarget(block); + notifyCanvasEditorActive(applyTarget, view); + syncEditorSplitLayout({ mountRoot: canvasEditorMountRoot(block), view }); + }); + header.addEventListener('nx-canvas-undo', () => { + canvasEditorMountRoot(block).querySelector('ew-editor-doc')?.undo(); + }); + header.addEventListener('nx-canvas-redo', () => { + canvasEditorMountRoot(block).querySelector('ew-editor-doc')?.redo(); + }); + block.before(header); + return header; +} + +export default async function decorate(block) { + const header = installCanvasHeader(block); + + const mountRoot = canvasEditorMountRoot(block); + mountRoot.classList.add('nx-canvas-editor-mount'); + syncEditorSplitLayout({ mountRoot, view: header.editorView }); + installEditorSplitDrag(mountRoot); + + mountRoot.addEventListener('nx-editor-undo-state', (e) => { + header.undoAvailable = e.detail?.canUndo ?? false; + header.redoAvailable = e.detail?.canRedo ?? false; + }); + + hashChange.subscribe((state) => { + syncCanvasEditorsToHash({ mountRoot, header, state }); + const toolPanel = document.querySelector('aside.panel[data-position="after"] ew-tool-panel'); + if (toolPanel) syncToolPanelViews(toolPanel, state); + }); + + const store = getPanelStore(); + if (store.before && !store.before.fragment) openCanvasPanel('before'); + if (store.after && !store.after.fragment) openCanvasPanel('after'); + + // Only NodeSelection (explicit block handle click) in doc mode qualifies as intentional context. + // wysiwyg has no block-select equivalent yet — see docs/canvas-events.md. + const CANVAS_CHAT_KEY = 'canvas-selection'; + editorSelectChange.subscribe(({ + blockIndex, blockName, proseIndex, innerText, source, explicit, + }) => { + if (source !== 'doc' || !explicit) return; + const detail = blockIndex >= 0 && blockName + ? { + key: CANVAS_CHAT_KEY, + id: CANVAS_CHAT_KEY, + label: blockName, + blockName, + proseIndex, + innerText, + } + : { key: CANVAS_CHAT_KEY }; + document.dispatchEvent(new CustomEvent('nx-add-to-chat', { detail })); + }); +} diff --git a/blocks/canvas/editor-utils/blocks.js b/blocks/canvas/editor-utils/blocks.js new file mode 100644 index 00000000..09df270f --- /dev/null +++ b/blocks/canvas/editor-utils/blocks.js @@ -0,0 +1,107 @@ +function getTableBlockName(tableNode) { + const firstRow = tableNode.firstChild; + if (!firstRow) return ''; + const firstCell = firstRow.firstChild; + if (!firstCell) return ''; + const raw = firstCell.textContent?.trim() ?? ''; + const match = raw.match(/^([a-zA-Z0-9_\s-]+)(?:\s*\([^)]*\))?$/); + return match ? match[1].trim().toLowerCase() : raw.toLowerCase(); +} + +function isSamePosition(from, to, dropPosition) { + return from === to || (to === from + 1 && dropPosition === 'before') + || (to === from - 1 && dropPosition === 'after'); +} + +export function getBlockPositions(view) { + if (!view?.state?.doc) return []; + const positions = []; + const { doc } = view.state; + doc.descendants((node, pos) => { + if (node.type.name === 'table') { + const blockName = getTableBlockName(node); + if (blockName === 'metadata') return; + positions.push(pos); + } + }); + return positions; +} + +export function getActiveBlockIndex(view) { + if (!view?.state) return -1; + const { state } = view; + const cursorPos = state.selection.from; + const positions = getBlockPositions(view); + for (let i = 0; i < positions.length; i += 1) { + const start = positions[i]; + const node = state.doc.nodeAt(start); + if (node && cursorPos >= start && cursorPos < start + node.nodeSize) return i; + } + return -1; +} + +export function moveBlock(view, fromIndex, toIndex, dropPosition) { + if (!view) return; + if (isSamePosition(fromIndex, toIndex, dropPosition)) return; + + const { doc } = view.state; + const positions = getBlockPositions(view); + + if (fromIndex >= positions.length || toIndex >= positions.length) return; + + const fromBlockPos = positions[fromIndex]; + const fromBlockNode = doc.nodeAt(fromBlockPos); + const toBlockPos = positions[toIndex]; + const toBlockNode = doc.nodeAt(toBlockPos); + + if (!fromBlockNode || !toBlockNode) return; + + const fromBlockSize = fromBlockNode.nodeSize; + const toBlockSize = toBlockNode.nodeSize; + + const insertPos = dropPosition === 'before' + ? toBlockPos + : toBlockPos + toBlockSize; + const adjustedInsertPos = insertPos > fromBlockPos + ? insertPos - fromBlockSize + : insertPos; + + view.dispatch( + view.state.tr + .delete(fromBlockPos, fromBlockPos + fromBlockSize) + .insert(adjustedInsertPos, fromBlockNode), + ); +} + +export function moveSection(view, fromSectionIndex, toSectionIndex, dropPosition) { + if (!view) return; + if (isSamePosition(fromSectionIndex, toSectionIndex, dropPosition)) return; + + const { doc, schema } = view.state; + + const sections = [[]]; + doc.forEach((node) => { + if (node.type === schema.nodes.horizontal_rule) { + sections.push([]); + } else { + sections[sections.length - 1].push(node); + } + }); + + if (fromSectionIndex >= sections.length || toSectionIndex >= sections.length) return; + + const reordered = [...sections]; + const [moved] = reordered.splice(fromSectionIndex, 1); + let insertIdx = dropPosition === 'before' ? toSectionIndex : toSectionIndex + 1; + if (insertIdx > fromSectionIndex) insertIdx -= 1; + reordered.splice(insertIdx, 0, moved); + + const hrNode = schema.nodes.horizontal_rule.create(); + const newNodes = []; + reordered.forEach((sectionNodes, i) => { + if (i > 0) newNodes.push(hrNode); + newNodes.push(...sectionNodes); + }); + + view.dispatch(view.state.tr.replaceWith(0, doc.content.size, newNodes)); +} diff --git a/blocks/canvas/editor-utils/command-defs.js b/blocks/canvas/editor-utils/command-defs.js new file mode 100644 index 00000000..dc8121e4 --- /dev/null +++ b/blocks/canvas/editor-utils/command-defs.js @@ -0,0 +1,267 @@ +/* eslint-disable import/no-unresolved -- importmap */ +import { DOMParser, Fragment } from 'da-y-wrapper'; +import { + blockType, + wrap, + list, + inlineMark, + sinkListLevel, + liftListLevel, + markIsActive, + inList, + canSinkList, + canLiftList, + getTableHeading, + getTableBody, + LOREM_SENTENCES, +} from './command-helpers.js'; + +const iconUrl = (name) => new URL(`../img/s2-icon-${name.toLowerCase()}-20-n.svg`, import.meta.url).href; + +export const COMMANDS = [ + // Toolbar: inline mark buttons + { + id: 'strong', + label: 'Bold', + schema: 'strong', + icon: iconUrl('TagBold'), + showIn: ['toolbar-marks'], + active: (state) => markIsActive(state, 'strong'), + apply: inlineMark('strong'), + }, + { + id: 'em', + label: 'Italic', + schema: 'em', + icon: iconUrl('TagItalic'), + showIn: ['toolbar-marks'], + active: (state) => markIsActive(state, 'em'), + apply: inlineMark('em'), + }, + { + id: 'code', + label: 'Inline code', + schema: 'code', + icon: iconUrl('Code'), + showIn: ['toolbar-marks'], + active: (state) => markIsActive(state, 'code'), + apply: inlineMark('code'), + }, + { + id: 'underline', + label: 'Underline', + schema: 'u', + icon: iconUrl('TagUnderline'), + showIn: ['toolbar-marks'], + active: (state) => markIsActive(state, 'u'), + apply: inlineMark('u'), + }, + { + id: 'strikethrough', + label: 'Strikethrough', + schema: 's', + icon: iconUrl('TagStrikeThrough'), + showIn: ['toolbar-marks'], + active: (state) => markIsActive(state, 's'), + apply: inlineMark('s'), + }, + + // Toolbar: block-type picker + { + id: 'paragraph', + label: 'Paragraph', + schema: 'paragraph', + showIn: ['toolbar-picker'], + apply: blockType('paragraph'), + }, + { + id: 'heading-1', + label: 'Heading 1', + icon: iconUrl('Heading1'), + schema: 'heading', + showIn: ['toolbar-picker', 'slash-text'], + apply: blockType('heading', { level: 1 }), + }, + { + id: 'heading-2', + label: 'Heading 2', + icon: iconUrl('Heading2'), + schema: 'heading', + showIn: ['toolbar-picker', 'slash-text'], + apply: blockType('heading', { level: 2 }), + }, + { + id: 'heading-3', + label: 'Heading 3', + icon: iconUrl('Heading3'), + schema: 'heading', + showIn: ['toolbar-picker', 'slash-text'], + apply: blockType('heading', { level: 3 }), + }, + { + id: 'heading-4', + label: 'Heading 4', + icon: iconUrl('Heading4'), + schema: 'heading', + showIn: ['toolbar-picker', 'slash-text'], + apply: blockType('heading', { level: 4 }), + }, + { + id: 'heading-5', + label: 'Heading 5', + icon: iconUrl('Heading5'), + schema: 'heading', + showIn: ['toolbar-picker', 'slash-text'], + apply: blockType('heading', { level: 5 }), + }, + { + id: 'heading-6', + label: 'Heading 6', + icon: iconUrl('Heading6'), + schema: 'heading', + showIn: ['toolbar-picker', 'slash-text'], + apply: blockType('heading', { level: 6 }), + }, + { + id: 'code-block', + label: 'Code block', + icon: iconUrl('BlockCode'), + schema: 'code_block', + showIn: ['toolbar-picker', 'slash-text'], + disabled: (state) => state.selection.$from.parent.type.name === 'code_block', + apply: blockType('code_block'), + }, + + // Toolbar: structure buttons + { + id: 'blockquote', + label: 'Blockquote', + icon: iconUrl('BlockQuote'), + schema: 'blockquote', + showIn: ['toolbar-structure', 'slash-text'], + apply: wrap('blockquote'), + }, + { + id: 'bullet-list', + label: 'Bullet list', + icon: iconUrl('ListBulleted'), + schema: 'bullet_list', + showIn: ['toolbar-structure', 'slash-text'], + visible: ({ selection: { $from } }) => !inList($from), + apply: list('bullet_list'), + }, + { + id: 'numbered-list', + label: 'Numbered list', + icon: iconUrl('ListNumbered'), + schema: 'ordered_list', + showIn: ['toolbar-structure', 'slash-text'], + visible: ({ selection: { $from } }) => !inList($from), + apply: list('ordered_list'), + }, + { + id: 'list-indent', + label: 'Indent list', + icon: iconUrl('TextIndentIncrease'), + showIn: ['toolbar-structure'], + visible: ({ selection: { $from } }) => inList($from), + disabled: (state) => !canSinkList(state), + apply: sinkListLevel, + }, + { + id: 'list-outdent', + label: 'Outdent list', + icon: iconUrl('TextIndentDecrease'), + showIn: ['toolbar-structure'], + visible: ({ selection: { $from } }) => inList($from), + disabled: (state) => !canLiftList(state), + apply: liftListLevel, + }, + + // Slash menu: text section only + { + id: 'section-break', + label: 'Section break', + icon: iconUrl('Separator'), + showIn: ['slash-text'], + apply: (view) => { + const div = document.createElement('div'); + div.append(document.createElement('hr'), document.createElement('p')); + const nodes = DOMParser.fromSchema(view.state.schema).parse(div); + view.dispatch(view.state.tr.replaceSelectionWith(nodes)); + }, + }, + { + id: 'lorem-ipsum', + label: 'Lorem ipsum', + icon: iconUrl('Rail'), + showIn: ['slash-text'], + apply: (view) => { + const { $cursor } = view.state.selection; + if (!$cursor) return; + const text = Array.from( + { length: 5 }, + (_, i) => LOREM_SENTENCES[i % LOREM_SENTENCES.length], + ).join(' '); + view.dispatch( + view.state.tr.replaceWith($cursor.before(), $cursor.pos, view.state.schema.text(text)), + ); + }, + }, + + // Slash menu: blocks section + { + id: 'open-library', + label: 'Open library', + icon: iconUrl('CCLibrary'), + showIn: ['slash-blocks'], + apply: () => { + const evt = new CustomEvent('nx-canvas-open-panel', { + bubbles: true, + composed: true, + detail: { position: 'after', viewId: 'blocks' }, + }); + document.querySelector('ew-canvas-header')?.dispatchEvent(evt); + }, + }, + { + id: 'insert-block', + label: 'Insert block', + icon: iconUrl('TableAdd'), + showIn: ['slash-blocks'], + apply: (view) => { + const { state } = view; + const heading = getTableHeading(state.schema); + const body = getTableBody(state.schema); + const frag = document.createDocumentFragment(); + frag.append(document.createElement('p')); + const para = DOMParser.fromSchema(state.schema).parse(frag); + const node = state.schema.nodes.table.create(null, Fragment.fromArray([heading, body])); + const trx = state.tr.insert(state.selection.head, para); + trx.replaceSelectionWith(node).scrollIntoView(); + view.dispatch(trx); + }, + }, +]; + +export function commandsFor(showIn) { + return COMMANDS.filter((c) => c.showIn.includes(showIn)); +} + +export const COMMAND_BY_ID = new Map(COMMANDS.map((c) => [c.id, c])); + +const SLASH_GROUPS = [ + { section: 'Blocks', showIn: 'slash-blocks' }, + { section: 'Text', showIn: 'slash-text' }, +]; + +export function slashMenuItemsForQuery(query) { + const q = (query || '').toLowerCase(); + const groups = SLASH_GROUPS + .map(({ section, showIn }) => ({ + section, + items: commandsFor(showIn).filter((i) => !q || i.label.toLowerCase().startsWith(q)), + })) + .filter((g) => g.items.length > 0); + return groups.flatMap(({ section, items }) => [{ section }, ...items]); +} diff --git a/blocks/canvas/editor-utils/command-helpers.js b/blocks/canvas/editor-utils/command-helpers.js new file mode 100644 index 00000000..c890f81a --- /dev/null +++ b/blocks/canvas/editor-utils/command-helpers.js @@ -0,0 +1,214 @@ +/* eslint-disable import/no-unresolved -- importmap */ +import { + Fragment, + liftListItem, + setBlockType, + sinkListItem, + TextSelection, + toggleMark, + wrapIn, + wrapInList, +} from 'da-y-wrapper'; + +/* ---- Apply factories ---- */ + +export const blockType = (nodeKey, attrs) => (view) => { + const { state } = view; + setBlockType(state.schema.nodes[nodeKey], attrs)(state, view.dispatch.bind(view)); +}; + +export const wrap = (nodeKey) => (view) => { + const { state } = view; + wrapIn(state.schema.nodes[nodeKey])(state, view.dispatch.bind(view)); +}; + +export const list = (nodeKey) => (view) => { + const { state } = view; + wrapInList(state.schema.nodes[nodeKey])(state, view.dispatch.bind(view)); +}; + +export const inlineMark = (markKey) => (view) => { + const { state } = view; + toggleMark(state.schema.marks[markKey])(state, view.dispatch.bind(view)); +}; + +export const sinkListLevel = (view) => { + const { state } = view; + sinkListItem(state.schema.nodes.list_item)(state, view.dispatch.bind(view)); +}; + +export const liftListLevel = (view) => { + const { state } = view; + liftListItem(state.schema.nodes.list_item)(state, view.dispatch.bind(view)); +}; + +/* ---- Active queries ---- */ + +export function markIsActive(state, markName) { + const mark = state.schema.marks[markName]; + if (!mark) return false; + const { selection, storedMarks } = state; + if (selection.empty) { + return (storedMarks || selection.$from.marks()).some((m) => m.type === mark); + } + return state.doc.rangeHasMark(selection.from, selection.to, mark); +} + +export function inBlockquote($pos) { + for (let d = $pos.depth; d > 0; d -= 1) { + if ($pos.node(d).type.name === 'blockquote') return true; + } + return false; +} + +export function nearestListType($pos) { + for (let d = $pos.depth; d > 0; d -= 1) { + const { name } = $pos.node(d).type; + if (name === 'bullet_list' || name === 'ordered_list') return name; + } + return null; +} + +export function inList($pos) { + return nearestListType($pos) !== null; +} + +export function canSinkList(state) { + return sinkListItem(state.schema.nodes.list_item)(state); +} + +export function canLiftList(state) { + return liftListItem(state.schema.nodes.list_item)(state); +} + +/* ---- Slash-only action helpers ---- */ + +export function getTableHeading(schema) { + // eslint-disable-next-line camelcase + const { paragraph, table_row, table_cell } = schema.nodes; + const para = paragraph.create(null, schema.text('columns')); + // eslint-disable-next-line camelcase + return table_row.create(null, Fragment.from(table_cell.create({ colspan: 2 }, para))); +} + +export function getTableBody(schema) { + const cell = schema.nodes.table_cell.createAndFill(); + return schema.nodes.table_row.create(null, Fragment.fromArray([cell, cell])); +} + +export const LOREM_SENTENCES = [ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.', + 'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore.', + 'Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia.', + 'Nunc feugiat mi a tellus consequat imperdiet.', + 'Vestibulum sapien proin quam etiam ultrices suscipit gravida bibendum.', + 'Fusce pellentesque enim aliquam varius tincidunt aenean vulputate.', + 'Maecenas volutpat blandit aliquam etiam erat velit scelerisque in dictum.', +]; + +/* ---- Link queries ---- */ + +function findLinkInRange(state) { + const { from, to } = state.selection; + const linkType = state.schema.marks.link; + let found; + state.doc.nodesBetween(from, to, (node, pos) => { + if (found) return false; + const mark = linkType.isInSet(node.marks); + if (mark) { found = { node, mark, from: pos, to: pos + node.nodeSize }; } + return true; + }); + return found ?? null; +} + +export function selectionHasLink(state) { + return findLinkInRange(state) !== null; +} + +export function getLinkInfoInSelection(state) { + const result = findLinkInRange(state); + if (!result) return null; + return { + href: result.mark.attrs.href ?? '', + title: result.mark.attrs.title ?? '', + text: result.node.textContent, + from: result.from, + to: result.to, + }; +} + +/* ---- Link commands ---- */ + +export function applyLink(view, { href, text }) { + const { state } = view; + const { schema, selection } = state; + const linkType = schema.marks.link; + let { from, to } = selection; + let { tr } = state; + + const existingLink = findLinkInRange(state); + if (existingLink) { + ({ from, to } = existingLink); + tr = tr.removeMark(from, to, linkType); + } + + const displayText = text?.trim() || href; + const originalText = state.doc.textBetween(from, to); + + if (displayText !== originalText || from === to) { + const marks = from < state.doc.content.size + ? state.doc.resolve(from).marks().filter((m) => m.type !== linkType) + : []; + const textNode = schema.text(displayText, marks); + tr = tr.replaceWith(from, to, textNode); + to = from + displayText.length; + } + + tr = tr.addMark(from, to, linkType.create({ href: href.trim() })); + tr = tr.setSelection(TextSelection.create(tr.doc, to)); + view.dispatch(tr); +} + +export function removeLink(view) { + const { state } = view; + const linkType = state.schema.marks.link; + const found = findLinkInRange(state); + if (!found) return; + const { tr } = state; + tr.removeMark(found.from, found.to, linkType); + view.dispatch(tr); +} + +/* ---- Block-type picker value ---- */ + +const SCHEMA_NODE_TO_ID = new Map([ + ['paragraph', 'paragraph'], + ['code_block', 'code-block'], +]); + +function forEachTextblockInSelection({ doc, selection }, visit) { + doc.nodesBetween(selection.from, selection.to, (node) => { + if (node.isTextblock) { + visit(node); + return false; + } + return true; + }); +} + +export function getBlockTypePickerValue(state) { + const keys = []; + forEachTextblockInSelection(state, (node) => { + if (node.type.name === 'heading') { + keys.push(`heading-${node.attrs.level}`); + } else { + keys.push(SCHEMA_NODE_TO_ID.get(node.type.name) ?? node.type.name); + } + }); + const uniq = [...new Set(keys)]; + if (uniq.length === 0) return 'paragraph'; + if (uniq.length > 1) return 'mixed'; + return uniq[0]; +} diff --git a/blocks/canvas/editor-utils/editor-utils.js b/blocks/canvas/editor-utils/editor-utils.js new file mode 100644 index 00000000..7810493e --- /dev/null +++ b/blocks/canvas/editor-utils/editor-utils.js @@ -0,0 +1,332 @@ +import { TextSelection } from 'da-y-wrapper'; +import prose2aem from '../../shared/prose2aem.js'; +import { getNx } from '../../../scripts/utils.js'; +import { daFetch } from '../../shared/utils.js'; + +const { DA_CONTENT } = await import(`${getNx()}/utils/utils.js`); + +// --- state.js --- + +function findInsertedRange(oldText, newText) { + if (newText.length <= oldText.length) return null; + let prefixLen = 0; + const maxPrefix = Math.min(oldText.length, newText.length); + while (prefixLen < maxPrefix && oldText[prefixLen] === newText[prefixLen]) prefixLen += 1; + return { start: prefixLen, end: prefixLen + (newText.length - oldText.length) }; +} + +export function updateState(data, ctx) { + const { view } = ctx; + // Capture stored marks before the transaction — these are marks the user toggled + // (e.g. Bold) that ProseMirror is holding for the next character typed. In + // WYSIWYG mode, keystrokes go to the iframe so ProseMirror's normal mark + // application on input never runs; we must apply them ourselves here. + const { storedMarks } = view.state; + const node = view.state.schema.nodeFromJSON(data.node); + const pos = view.state.doc.resolve(data.cursorOffset); + const docPos = view.state.selection.from; + + const nodeStart = pos.before(pos.depth); + const nodeEnd = pos.after(pos.depth); + + const { tr } = view.state; + tr.replaceWith(nodeStart, nodeEnd, node); + + let appliedMarks = false; + if (storedMarks?.length) { + const oldText = view.state.doc.textBetween(nodeStart, nodeEnd); + const inserted = findInsertedRange(oldText, node.textContent); + if (inserted) { + // In ProseMirror each text character occupies one position unit, so + // text offset i maps to doc position nodeStart + 1 + i. + const markFrom = nodeStart + 1 + inserted.start; + const markTo = nodeStart + 1 + inserted.end; + storedMarks.forEach((mark) => tr.addMark(markFrom, markTo, mark)); + // Preserve stored marks so continued typing stays in the same formatting state. + tr.setStoredMarks(storedMarks); + appliedMarks = true; + } + } + + tr.setSelection(TextSelection.create(tr.doc, docPos)); + + ctx.suppressRerender = true; + view.dispatch(tr); + ctx.suppressRerender = false; + + // Sync the updated node (with marks applied) back to the portal's mini editor. + // Without this, the portal's editor retains the plain-text version, so the next + // character typed would send a node-update that overwrites the marks we just added + // (replaceWith replaces the whole paragraph with the portal's plain content). + if (appliedMarks && ctx.port) { + try { + const syncPos = view.state.doc.resolve(data.cursorOffset); + const syncNodeStart = syncPos.before(syncPos.depth); + const syncNode = view.state.doc.resolve(syncNodeStart).nodeAfter; + if (syncNode) { + ctx.port.postMessage({ + type: 'set-editor-state', + editorState: syncNode.toJSON(), + cursorOffset: data.cursorOffset, + }); + } + } catch { + // Non-fatal: position errors after structural changes + } + } +} + +export function getEditor(data, ctx) { + if (ctx.suppressRerender) return; + const { view } = ctx; + const { cursorOffset } = data; + if (typeof cursorOffset !== 'number') return; + + const { doc } = view.state; + const maxPos = doc.content.size; + if (cursorOffset < 0 || cursorOffset > maxPos) return; + + try { + const pos = doc.resolve(cursorOffset); + const before = pos.before(pos.depth); + const beforePos = doc.resolve(before); + const nodeAtBefore = beforePos.nodeAfter; + if (!nodeAtBefore) return; + ctx.port.postMessage({ type: 'set-editor-state', editorState: nodeAtBefore.toJSON(), cursorOffset: before + 1 }); + } catch { + // Stale iframe cursor after structural replace (e.g. chat revert, remote sync). + } +} + +// --- document.js --- + +const EDITABLES = [ + { selector: 'h1', nodeName: 'H1' }, + { selector: 'h2', nodeName: 'H2' }, + { selector: 'h3', nodeName: 'H3' }, + { selector: 'h4', nodeName: 'H4' }, + { selector: 'h5', nodeName: 'H5' }, + { selector: 'h6', nodeName: 'H6' }, + { selector: 'p', nodeName: 'P' }, + { selector: 'ol', nodeName: 'OL' }, + { selector: 'ul', nodeName: 'UL' }, +]; +const EDITABLE_SELECTORS = EDITABLES.map((edit) => edit.selector).join(', '); + +export function extractCursors(view) { + const remoteCursors = view.dom.querySelectorAll('.ProseMirror-yjs-cursor'); + const cursorMap = new Map(); + + remoteCursors.forEach((remoteCursor) => { + let highestEditable = null; + let current = remoteCursor.parentElement; + + while (current) { + if (current.matches?.(EDITABLE_SELECTORS)) { + highestEditable = current; + } + current = current.parentElement; + } + + if (!highestEditable) return; + + try { + const proseIndex = view.posAtDOM(highestEditable, 0); + cursorMap.set(proseIndex, { + proseIndex, + remote: remoteCursor.innerText, + color: remoteCursor.style['border-color'], + }); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Could not find position for remote cursor:', e); + } + }); + + return [...cursorMap.values()]; +} + +export function getInstrumentedHTML(view) { + const editorClone = view.dom.cloneNode(true); + + const originalElements = view.dom.querySelectorAll(EDITABLE_SELECTORS); + const clonedElements = editorClone.querySelectorAll(EDITABLE_SELECTORS); + + originalElements.forEach((originalElement, index) => { + if (clonedElements[index]) { + try { + const editableElementStartPos = view.posAtDOM(originalElement, 0); + clonedElements[index].setAttribute('data-prose-index', editableElementStartPos); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Could not find position for element:', e); + } + } + }); + + // Block instrumentation (same as da-nx qe-advanced): wrap blocks (e.g. tables), add a + // sentinel with data-prose-index, then after serialization move it to wrapper as data-block-index + const originalTables = view.dom.querySelectorAll('table'); + const clonedTables = editorClone.querySelectorAll('table'); + clonedTables.forEach((table, index) => { + const div = table.parentElement; + const blockMarker = document.createElement('div'); + blockMarker.className = 'block-marker'; + try { + const position = view.posAtDOM(originalTables[index], 0); + blockMarker.setAttribute('data-prose-index', position); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Could not find position for table block:', e); + } + div.insertAdjacentElement('beforebegin', blockMarker); + }); + + const remoteCursors = editorClone.querySelectorAll('.ProseMirror-yjs-cursor'); + + remoteCursors.forEach((remoteCursor) => { + let highestEditable = null; + let current = remoteCursor.parentElement; + + while (current) { + if (current.hasAttribute('data-prose-index')) { + highestEditable = current; + } + current = current.parentElement; + } + + if (highestEditable) { + highestEditable.setAttribute('data-cursor-remote', remoteCursor.innerText); + highestEditable.setAttribute('data-cursor-remote-color', remoteCursor.style['border-color']); + } + }); + + // Serialize clone to HTML, then move block-marker index onto wrapper as data-block-index + // (same pattern as da-nx qe-advanced: getInstrumentedHTML in prose2aem.js). + let htmlString = prose2aem(editorClone, true, false, true); + htmlString = htmlString.replace( + /
<\/div>\s*]*?)>/gi, + (_match, proseIndex, divAttributes) => ``, + ); + return htmlString; +} + +const SKIP_BLOCK_CLASSES = new Set(['default-content-wrapper', 'metadata', 'block-marker']); + +export function parseSections(htmlText) { + const doc = new DOMParser().parseFromString(htmlText, 'text/html'); + const container = doc.querySelector('main') ?? doc.body; + let flatIndex = 0; + return Array.from(container.querySelectorAll(':scope > div'), (section, sectionIndex) => { + const blocks = []; + Array.from(section.querySelectorAll(':scope > div[class]')).forEach((el) => { + const name = el.classList[0]; + if (!name || SKIP_BLOCK_CLASSES.has(name)) return; + const rawProseIndex = el.getAttribute('data-block-index'); + const proseIndex = rawProseIndex != null ? Number(rawProseIndex) : undefined; + const innerText = el.textContent?.trim() ?? ''; + blocks.push({ name, blockIndex: flatIndex, proseIndex, innerText }); + flatIndex += 1; + }); + return { sectionIndex, blocks }; + }); +} + +// State observable — replays last value on subscribe. See docs/canvas-events.md. +export const editorHtmlChange = (() => { + const listeners = new Set(); + let currentHtml = ''; + return { + emit(html) { + currentHtml = html; + listeners.forEach((fn) => fn(html)); + }, + subscribe(fn) { + listeners.add(fn); + if (currentHtml) fn(currentHtml); + return () => listeners.delete(fn); + }, + }; +})(); + +// Event observable — no replay on subscribe. See docs/canvas-events.md. +// emit() enriches the detail with blockName/proseIndex/innerText from the last parsed HTML. +export const editorSelectChange = (() => { + const listeners = new Set(); + let blockMeta = new Map(); + + editorHtmlChange.subscribe((html) => { + if (!html.trim()) { + blockMeta = new Map(); + return; + } + const next = new Map(); + for (const { blocks } of parseSections(html)) { + for (const { name, blockIndex, proseIndex, innerText } of blocks) { + next.set(blockIndex, { name, proseIndex, innerText }); + } + } + blockMeta = next; + }); + + return { + emit(detail) { + const meta = blockMeta.get(detail.blockIndex); + const { name: blockName, proseIndex, innerText } = meta || {}; + const enriched = meta + ? { ...detail, blockName, proseIndex, innerText } + : detail; + listeners.forEach((fn) => fn(enriched)); + }, + subscribe(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + }; +})(); + +export function updateDocument(ctx) { + if (ctx.suppressRerender) return undefined; + const body = getInstrumentedHTML(ctx.view); + ctx.port.postMessage({ type: 'set-body', body }); + return body; +} + +export function updateCursors(ctx) { + const cursors = extractCursors(ctx.view); + ctx.port.postMessage({ type: 'set-cursors', cursors }); +} + +// --- preview.js --- + +export function getPreviewOrigin(org, repo) { + const hostname = window?.location?.hostname ?? ''; + const domain = hostname.endsWith('aem.page') || hostname.endsWith('localhost') + ? 'stage-preview.da.live' + : 'preview.da.live'; + return `https://main--${repo}--${org}.${domain}`; +} + +export async function fetchWysiwygCookie({ org, repo, token }) { + if (!org || !repo || !token) { + throw new Error('fetchWysiwygCookie: org, repo, and token required'); + } + const previewUrl = `${getPreviewOrigin(org, repo)}/gimme_cookie`; + const contentUrl = `${DA_CONTENT}/${org}/${repo}/.gimme_cookie`; + + const previewResp = await daFetch(previewUrl, { method: 'GET', credentials: 'include', headers: { Authorization: `Bearer ${token}` } }); + if (!previewResp.ok) { + throw new Error(`gimme_cookie preview failed: status ${previewResp.status}`); + } + + try { + const contentResp = await fetch(contentUrl, { method: 'GET', credentials: 'include' }); + if (!contentResp.ok) { + // eslint-disable-next-line no-console + console.warn('[canvas:wysiwyg] content gimme_cookie non-ok (non-fatal)', contentResp.status); + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[canvas:wysiwyg] content gimme_cookie failed (non-fatal)', e?.message); + } +} diff --git a/blocks/canvas/editor-utils/extensions-bridge.js b/blocks/canvas/editor-utils/extensions-bridge.js new file mode 100644 index 00000000..241701a3 --- /dev/null +++ b/blocks/canvas/editor-utils/extensions-bridge.js @@ -0,0 +1,20 @@ +/* eslint-disable import/no-unresolved -- importmap */ +import { Plugin } from 'da-y-wrapper'; + +const bridge = { view: null }; + +export function getExtensionsBridge() { + return bridge; +} + +export function createExtensionsBridgePlugin() { + return new Plugin({ + view(editorView) { + bridge.view = editorView; + return { + update(view) { bridge.view = view; }, + destroy() { bridge.view = null; }, + }; + }, + }); +} diff --git a/blocks/canvas/editor-utils/prose-diff.js b/blocks/canvas/editor-utils/prose-diff.js new file mode 100644 index 00000000..a643ef6c --- /dev/null +++ b/blocks/canvas/editor-utils/prose-diff.js @@ -0,0 +1,172 @@ +import { Plugin } from 'da-y-wrapper'; + +export function findChangedNodes(oldDoc, newDoc) { + const changes = []; + + function traverse(oldNode, newNode, pos) { + if (oldNode === newNode) return; + + if (!oldNode || !newNode || oldNode.type !== newNode.type) { + changes.push({ + type: 'replaced', + pos, + oldNode, + newNode, + }); + return; + } + + if (oldNode.isText && newNode.isText) { + if (oldNode.text !== newNode.text) { + changes.push({ + type: 'text', + pos, + oldText: oldNode.text, + newText: newNode.text, + }); + return; + } + } + + if (oldNode.isText || newNode.isText) { + const oldMarks = oldNode.marks || []; + const newMarks = newNode.marks || []; + if (oldMarks.length !== newMarks.length + || !oldMarks.every((m, i) => m.eq(newMarks[i]))) { + changes.push({ + type: 'marks', + pos, + oldMarks, + newMarks, + }); + } + } + + if (!oldNode.sameMarkup(newNode)) { + changes.push({ + type: 'attrs', + pos, + oldAttrs: oldNode.attrs, + newAttrs: newNode.attrs, + }); + } + + const oldSize = oldNode.childCount; + const newSize = newNode.childCount; + const minSize = Math.min(oldSize, newSize); + + let oldPos = pos + 1; + let newPos = pos + 1; + + for (let i = 0; i < minSize; i += 1) { + const oldChild = oldNode.child(i); + const newChild = newNode.child(i); + traverse(oldChild, newChild, oldPos); + oldPos += oldChild.nodeSize; + newPos += newChild.nodeSize; + } + + if (newSize > oldSize) { + for (let i = oldSize; i < newSize; i += 1) { + const newChild = newNode.child(i); + changes.push({ + type: 'added', + pos: newPos, + node: newChild, + }); + newPos += newChild.nodeSize; + } + } + + if (oldSize > newSize) { + for (let i = newSize; i < oldSize; i += 1) { + const oldChild = oldNode.child(i); + changes.push({ + type: 'deleted', + pos: oldPos, + node: oldChild, + }); + oldPos += oldChild.nodeSize; + } + } + } + + traverse(oldDoc, newDoc, 0); + return changes; +} + +export const EDITABLE_TYPES = ['heading', 'paragraph', 'ordered_list', 'bullet_list']; + +export function findCommonEditableAncestor(view, changes, prevState) { + if (changes.length === 0) return null; + + const editableAncestors = []; + + for (const change of changes) { + const isDeletedNode = change.type === 'deleted'; + try { + const doc = isDeletedNode ? prevState.doc : view.state.doc; + const $pos = doc.resolve(change.pos); + let editableAncestor = null; + + for (let { depth } = $pos; depth > 0; depth -= 1) { + const node = $pos.node(depth); + if (EDITABLE_TYPES.includes(node.type.name)) { + editableAncestor = { + node, + pos: $pos.before(depth), + }; + } + } + + if (editableAncestor) { + editableAncestors.push(editableAncestor); + } else if (!isDeletedNode) { + return null; + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Could not resolve position for change:', e); + return null; + } + } + + if (editableAncestors.length === 0) return null; + + const firstPos = editableAncestors[0].pos; + const allSameAncestor = editableAncestors.every((ancestor) => ancestor.pos === firstPos); + + return allSameAncestor ? editableAncestors[0] : null; +} + +export function createTrackingPlugin(rerenderPage, updateCursors, getEditor, onSelectionChange) { + return new Plugin({ + view() { + return { + update(view, prevState) { + const docChanged = view.state.doc !== prevState.doc; + + if (docChanged) { + const changes = findChangedNodes(prevState.doc, view.state.doc); + + if (changes.length > 0) { + const commonEditable = findCommonEditableAncestor(view, changes, prevState); + + if (commonEditable) { + getEditor?.({ cursorOffset: commonEditable.pos + 1 }); + } else { + rerenderPage?.(); + } + } + } + + updateCursors?.(); + + if (view.state.selection !== prevState.selection) { + onSelectionChange?.(view); + } + }, + }; + }, + }); +} diff --git a/blocks/canvas/editor-utils/selection-toolbar.js b/blocks/canvas/editor-utils/selection-toolbar.js new file mode 100644 index 00000000..2638ade9 --- /dev/null +++ b/blocks/canvas/editor-utils/selection-toolbar.js @@ -0,0 +1,94 @@ +/* eslint-disable import/no-unresolved -- importmap */ +import { Plugin, PluginKey, NodeSelection } from 'da-y-wrapper'; + +const NON_TEXT_NODES = new Set(['table', 'image']); + +/** Set on transactions that mirror WYSIWYG iframe text selection into ProseMirror. */ +export const NX_QUICK_EDIT_IFRAME_SELECTION_META = 'nxQuickEditIframeSelection'; + +/** Clears iframe-origin flag when the iframe reports a caret (no range). */ +export const NX_QUICK_EDIT_CLEAR_IFRAME_SELECTION_ORIGIN_META = 'nxClearQuickEditIframeSelectionOrigin'; + +const selectionToolbarOriginKey = new PluginKey('nxSelectionToolbarOrigin'); + +function getSelectionOriginFromIframe(state) { + return selectionToolbarOriginKey.getState(state)?.fromIframe ?? false; +} + +export const TOOLBAR_PADDING_GAP = 64; + +let toolbar; +let componentLoaded; + +export function getSelectionToolbar() { + if (toolbar) return toolbar; + componentLoaded ??= import('../ew-selection-toolbar/ew-selection-toolbar.js'); + toolbar = document.createElement('ew-selection-toolbar'); + document.body.append(toolbar); + return toolbar; +} + +export function hideSelectionToolbar() { + toolbar?.hide?.(); +} + +function isNonTextSelection({ selection }) { + return selection instanceof NodeSelection + && NON_TEXT_NODES.has(selection.node.type.name); +} + +function syncToolbar(view) { + if (!view) return; + const tb = getSelectionToolbar(); + if (tb.linkDialogOpen) return; + if (view.state.selection.empty || isNonTextSelection(view.state)) { + hideSelectionToolbar(); + return; + } + const start = view.coordsAtPos(view.state.selection.from); + tb.view = view; + tb.show({ x: start.left, y: start.top - TOOLBAR_PADDING_GAP }); +} + +export function createSelectionToolbarPlugin() { + return new Plugin({ + key: selectionToolbarOriginKey, + state: { + init: () => ({ fromIframe: false }), + apply(tr, prev) { + if (tr.getMeta(NX_QUICK_EDIT_IFRAME_SELECTION_META)) return { fromIframe: true }; + if (tr.getMeta(NX_QUICK_EDIT_CLEAR_IFRAME_SELECTION_ORIGIN_META)) { + return { fromIframe: false }; + } + if (tr.selectionSet) return { fromIframe: false }; + return prev; + }, + }, + view() { + let scrollEl; + const tb = getSelectionToolbar(); + const onScroll = () => { + if (tb.view && getSelectionOriginFromIframe(tb.view.state)) return; + syncToolbar(tb.view); + }; + + return { + update(view) { + if (!scrollEl) { + scrollEl = view.dom.closest('.ew-editor-doc'); + scrollEl?.addEventListener('scroll', onScroll, { passive: true }); + } + const header = document.querySelector('ew-canvas-header'); + const ev = header?.editorView; + if (ev !== 'content' && ev !== 'split') return; + if (getSelectionOriginFromIframe(view.state)) return; + syncToolbar(view); + }, + destroy() { + scrollEl?.removeEventListener('scroll', onScroll); + hideSelectionToolbar(); + }, + }; + }, + }); +} diff --git a/blocks/canvas/ew-canvas-header/ew-canvas-header.css b/blocks/canvas/ew-canvas-header/ew-canvas-header.css new file mode 100644 index 00000000..a3570e81 --- /dev/null +++ b/blocks/canvas/ew-canvas-header/ew-canvas-header.css @@ -0,0 +1,164 @@ +:host { + display: block; + box-sizing: border-box; + font-family: var( + --s2-font-family, + adobe-clean, + "Source Sans Pro", + "Trebuchet MS", + sans-serif + ); + font-size: var(--s2-body-size-s, 0.875rem); + color: var(--s2-gray-800); +} + +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + min-width: 24px; + min-height: 24px; + padding: 0 4px; + margin: 0; + border: none; + border-radius: 8px; + font: inherit; + font-size: var(--s2-body-size-xs, 0.75rem); + color: var(--s2-gray-800); + background: transparent; + cursor: pointer; +} + +.icon-btn img { + display: block; + flex-shrink: 0; + width: 16px; + height: 16px; +} + +.icon-btn:focus-visible { + outline: 2px solid var(--s2-blue-800); + outline-offset: 2px; +} + +.icon-btn:disabled { + color: var(--s2-gray-400); + cursor: not-allowed; +} + +.icon-btn:hover:not(:disabled) { + background-color: var(--s2-gray-75); +} + +.icon-btn:disabled img { + opacity: 0.45; +} + +.segmented { + display: inline-flex; + align-items: center; + padding: var(--s2-spacing-50); + border-radius: calc(var(--s2-corner-radius-400) + var(--s2-spacing-50)); + gap: var(--s2-spacing-50); + background-color: var(--s2-gray-100); +} + +.segment { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + margin: 0; + height: 24px; + padding: 0 9px; + border: none; + border-radius: var(--s2-corner-radius-400); + font: inherit; + font-size: var(--s2-body-size-xs); + line-height: 16px; + font-weight: var(--s2-component-s-medium-font-weight); + color: var(--s2-gray-700); + background: transparent; + cursor: pointer; + white-space: nowrap; + transition: + color 0.12s ease, + background-color 0.12s ease, + box-shadow 0.12s ease; + + &:focus { + outline: none; + } + + &:hover:not(.is-selected) { + color: var(--s2-gray-900); + background-color: var(--s2-gray-200); + } + + &.is-selected { + color: var(--s2-gray-800); + background-color: var(--s2-gray-25); + + &:focus { + box-shadow: 0 1px 2px rgb(0 0 0 / 6%); + } + } + + &.segment-icon { + padding: 0 6px; + min-width: 28px; + } + + &.segment-icon img { + display: block; + width: 16px; + height: 16px; + flex-shrink: 0; + } +} + +.bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + box-sizing: border-box; + height: var(--ew-canvas-header-height); + padding: 0 12px; + background-color: light-dark(#fff, var(--s2-gray-25)); + border-bottom: 1px solid var(--s2-gray-200); +} + +.group { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.group-center { + flex: 1; + justify-content: center; + min-width: 0; +} + +@media (width < 480px) { + .bar { + flex-wrap: wrap; + justify-content: center; + padding-block: 8px; + row-gap: 8px; + } + + .group-center { + order: 0; + flex: 1 1 100%; + justify-content: center; + } + + .group-start, + .group-end { + order: 1; + } +} diff --git a/blocks/canvas/ew-canvas-header/ew-canvas-header.js b/blocks/canvas/ew-canvas-header/ew-canvas-header.js new file mode 100644 index 00000000..7f3a532c --- /dev/null +++ b/blocks/canvas/ew-canvas-header/ew-canvas-header.js @@ -0,0 +1,135 @@ +import { LitElement, html } from 'da-lit'; + +import { getNx } from '../../../scripts/utils.js'; + +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); + +const style = await loadStyle(import.meta.url); + +const ICONS = { + undo: '/img/icons/s2-icon-undo-20-n.svg', + redo: '/img/icons/s2-icon-redo-20-n.svg', + splitLeft: '/blocks/canvas/img/s2-icon-splitleft-20-n.svg', + splitRight: '/blocks/canvas/img/s2-icon-splitright-20-n.svg', + gridCompare: '/blocks/canvas/img/s2-icon-gridcompare-20-n.svg', +}; + +const EDITOR_VIEWS = /** @type {const} */ (['layout', 'content', 'split']); + +class EWCanvasHeader extends LitElement { + static properties = { + /** `'layout'` / `'content'` = single pane; `'split'` = doc + WYSIWYG side by side */ + editorView: { type: String, reflect: true }, + undoAvailable: { type: Boolean }, + redoAvailable: { type: Boolean }, + }; + + constructor() { + super(); + this.editorView = 'layout'; + this.undoAvailable = false; + this.redoAvailable = false; + } + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + } + + _openPanel(position) { + this.dispatchEvent( + new CustomEvent('nx-canvas-open-panel', { + bubbles: true, + composed: true, + detail: { position }, + }), + ); + } + + _undo() { + this.dispatchEvent( + new CustomEvent('nx-canvas-undo', { bubbles: true, composed: true }), + ); + } + + _redo() { + this.dispatchEvent( + new CustomEvent('nx-canvas-redo', { bubbles: true, composed: true }), + ); + } + + _setEditorView(view) { + if (!EDITOR_VIEWS.includes(view) || view === this.editorView) return; + this.editorView = view; + this.dispatchEvent( + new CustomEvent('nx-canvas-editor-view', { + bubbles: true, + composed: true, + detail: { view }, + }), + ); + } + + _renderIcon(name) { + return html``; + } + + render() { + return html` +
+
+ + + +
+ +
+
+ + + +
+
+ +
+ +
+
+ `; + } +} + +customElements.define('ew-canvas-header', EWCanvasHeader); diff --git a/blocks/canvas/ew-editor-doc/ew-editor-doc.css b/blocks/canvas/ew-editor-doc/ew-editor-doc.css new file mode 100644 index 00000000..8eebd644 --- /dev/null +++ b/blocks/canvas/ew-editor-doc/ew-editor-doc.css @@ -0,0 +1,489 @@ +/* stylelint-disable selector-class-pattern -- ProseMirror and Yjs use their own class names */ + +.ew-editor-doc { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden auto; +} + +.ew-editor-doc .ew-editor-doc-mount { + flex: 1; + min-height: 0; + max-width: 800px; + margin: auto; + width: 100%; +} + +.ew-editor-doc .da-prose-mirror { + flex: 1; + min-height: 0; + position: relative; +} + +.ew-editor-doc .ProseMirror { + min-height: 200px; + background: light-dark(#fff, var(--s2-gray-75, #2c2c2c)); + color: light-dark(#222, var(--s2-gray-900, #f5f5f5)); + position: relative; + padding: 1rem; + overflow-wrap: anywhere; + white-space: pre-wrap; + box-sizing: border-box; +} + +.ew-editor-doc .ProseMirror-focused { + outline: none; +} + +.ew-editor-doc .da-slash-hint { + position: absolute; + pointer-events: none; + color: var(--s2-gray-500, rgb(143 143 143)); + white-space: nowrap; + user-select: none; +} + +.ew-editor-doc .ProseMirror > *:first-child { + margin-top: 0; +} + +.ew-editor-doc .ProseMirror img { + max-width: 100%; + height: auto; +} + +/* Table chrome matches da.live da-editor.css “COPY FROM TABLES”: wrapper holds the + outer border; table uses border-style hidden so cell borders do not stack on it. */ +.ew-editor-doc .ProseMirror table { + border-collapse: collapse; + table-layout: fixed; + width: 100% !important; + overflow: hidden; + border-style: hidden; +} + +.ew-editor-doc .ProseMirror td, +.ew-editor-doc .ProseMirror th { + border: 2px solid light-dark(#b1b1b1, var(--s2-gray-400, #6e6e6e)); + vertical-align: top; + min-width: 100px; + padding: 8px; + box-sizing: border-box; + position: relative; +} + +.ew-editor-doc .ProseMirror tr:first-child td, +.ew-editor-doc .ProseMirror tr:first-child th { + background: light-dark(#f1f1f1, var(--s2-gray-200, #3d3d3d)); + text-align: center; + font-weight: 700; +} + +.ew-editor-doc .ProseMirror td.selectedCell, +.ew-editor-doc .ProseMirror th.selectedCell { + background: light-dark(#e9f4ff, rgb(20 115 230 / 25%)); + box-shadow: inset 0 2px 5px rgb(0 0 0 / 12%); +} + +.ew-editor-doc .ProseMirror tr:first-child td.selectedCell, +.ew-editor-doc .ProseMirror tr:first-child th.selectedCell { + background: light-dark(#d1e4f8, rgb(20 115 230 / 35%)); +} + +.ew-editor-doc .ProseMirror td > *:first-child, +.ew-editor-doc .ProseMirror th > *:first-child { + margin-top: 0; +} + +.ew-editor-doc .ProseMirror td > *:last-child, +.ew-editor-doc .ProseMirror th > *:last-child { + margin-bottom: 0; +} + +.ew-editor-doc p code, +.ew-editor-doc pre { + background: light-dark(#e4ecfa, rgb(20 115 230 / 15%)); + border: 1px solid light-dark(#becee9, var(--s2-gray-500, #5c5c5c)); + padding: 0 4px; + border-radius: 4px; + font-size: 14px; +} + +.ew-editor-doc .ProseMirror pre { + white-space: pre-wrap; +} + +.ew-editor-doc blockquote { + position: relative; + padding: 0 0.5rem 0 1.5rem; + margin: 0; +} + +.ew-editor-doc blockquote::before { + position: absolute; + display: block; + content: ''; + top: 0; + bottom: 0; + left: 0; + width: 4px; + background: light-dark(#848484, var(--s2-gray-500, #8f8f8f)); + border-radius: 2px; +} + +.ew-editor-doc .ProseMirror-hideselection *::selection { + background: transparent; +} + +.ew-editor-doc .ProseMirror-selectednode { + outline: 2px solid var(--s2-blue-800, #1473e6); +} + +.ew-editor-doc .ProseMirror-gapcursor::after { + content: ""; + display: block; + position: absolute; + top: -2px; + width: 20px; + border-top: 1px solid light-dark(#000, #fff); + animation: nx-pm-cursor-blink 1.1s steps(2, start) infinite; +} + +@keyframes nx-pm-cursor-blink { + to { + visibility: hidden; + } +} + +.ew-editor-doc .ProseMirror .tableWrapper { + overflow-x: auto; + border: 2px solid light-dark(#b1b1b1, var(--s2-gray-400, #6e6e6e)); + border-radius: 6px; + margin: 2px 0; +} + +.ew-editor-doc .ProseMirror .column-resize-handle { + position: absolute; + right: -2px; + top: 0; + bottom: 0; + width: 4px; + z-index: 20; + background-color: light-dark(#adf, rgb(20 115 230 / 35%)); + pointer-events: none; +} + +.ew-editor-doc .ProseMirror.resize-cursor { + cursor: ew-resize; + cursor: col-resize; +} + +.ew-editor-doc .ProseMirror td > *:has(+ .column-resize-handle), +.ew-editor-doc .ProseMirror th > *:has(+ .column-resize-handle) { + margin-bottom: 0; +} + +.ew-editor-doc .ProseMirror-yjs-cursor { + position: relative; + margin-left: -1px; + margin-right: -1px; + border-left: 1px solid black; + border-right: 1px solid black; + border-color: orange; + word-break: normal; + pointer-events: none; +} + +.ew-editor-doc .ProseMirror-yjs-cursor > div { + position: absolute; + top: -1.05em; + left: -1px; + font-size: 13px; + background-color: rgb(250 129 0); + border-radius: 2px; + padding: 0 3px; + white-space: nowrap; + color: white; +} + +.ew-editor-doc-placeholder { + display: flex; + align-items: center; + justify-content: center; + color: light-dark(#505050, var(--s2-gray-700, #cacaca)); + font-size: 0.875rem; + padding: 1rem; + text-align: center; + height: 100%; + min-height: 200px; +} + +.ew-editor-doc .ew-editor-doc-placeholder code { + background: light-dark(#e5e5e5, var(--s2-gray-200, #3d3d3d)); + padding: 0.125rem 0.375rem; + border-radius: 2px; +} + +.ew-editor-doc-error { + color: light-dark(#c00, #ff6b6b); + font-size: 0.875rem; + padding: 0.5rem; +} + +/* Ported from da.live prose: table select handle + image focal point */ + +.ew-editor-doc .table-select-handle { + position: absolute; + width: 20px; + height: 20px; + background-color: light-dark(#fff, var(--s2-gray-75, #2c2c2c)); + background-image: url('https://da.live/img/icons/s2-icon-select-20-n.svg'); + background-repeat: no-repeat; + background-position: center; + background-size: 16px; + border: 1px solid light-dark(#ccc, var(--s2-gray-400, #6e6e6e)); + border-radius: 4px; + z-index: 100; + cursor: pointer; + display: none; + align-items: center; + justify-content: center; +} + +.ew-editor-doc .table-select-handle.is-visible { + display: flex; +} + +.ew-editor-doc .table-select-handle:hover { + background-color: light-dark(#f0f7ff, rgb(20 115 230 / 20%)); + border-color: var(--s2-blue-800, #1473e6); +} + +.ew-editor-doc .focal-point-image-wrapper { + position: relative; + display: block; +} + +.ew-editor-doc .focal-point-image-wrapper img { + display: block; + position: relative; +} + +.ew-editor-doc .focal-point-icon { + position: absolute; + bottom: 4px; + left: 4px; + width: 32px; + height: 32px; + background: light-dark(rgb(255 255 255 / 90%), rgb(44 44 44 / 90%)); + border: 1px solid light-dark(#ccc, var(--s2-gray-400, #6e6e6e)); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 10; + pointer-events: auto; +} + +.ew-editor-doc .focal-point-icon-active { + opacity: 1; +} + +.ew-editor-doc .focal-point-icon:hover { + background: light-dark(#fff, var(--s2-gray-75, #3d3d3d)); + border-color: var(--s2-blue-800, #1473e6); +} + +.ew-editor-doc .focal-point-image-wrapper:hover .focal-point-icon:not(.focal-point-icon-active) { + opacity: 1; +} + +.ew-editor-doc .focal-point-icon svg { + color: light-dark(#505050, var(--s2-gray-700, #cacaca)); +} + +.ew-editor-doc .focal-point-icon svg .fill { + fill: currentcolor; +} + +.ew-editor-doc .focal-point-icon:hover svg { + color: var(--s2-blue-800, #1473e6); +} + +/* Native focal point dialog (da.live used da-dialog) */ + +.nx-focal-point-dialog { + border: none; + border-radius: 8px; + padding: 0; + max-width: min(720px, 96vw); + background: light-dark(#fff, var(--s2-gray-75, #2c2c2c)); + color: light-dark(#222, var(--s2-gray-900, #f5f5f5)); + box-shadow: 0 8px 32px rgb(0 0 0 / 24%); +} + +.nx-focal-point-dialog::backdrop { + background: rgb(0 0 0 / 45%); +} + +.nx-focal-point-dialog__title { + margin: 0; + padding: 1rem 1.25rem; + font-size: 1.125rem; + font-weight: 600; + border-bottom: 1px solid light-dark(#e0e0e0, var(--s2-gray-400, #5c5c5c)); +} + +.nx-focal-point-dialog__footer { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: flex-end; + padding: 1rem 1.25rem; + border-top: 1px solid light-dark(#e0e0e0, var(--s2-gray-400, #5c5c5c)); +} + +.nx-focal-point-dialog__btn { + padding: 0.5rem 1rem; + border-radius: 6px; + font-size: 0.875rem; + cursor: pointer; + border: 1px solid light-dark(#c0c0c0, var(--s2-gray-500, #6e6e6e)); + background: light-dark(#f5f5f5, var(--s2-gray-200, #3d3d3d)); + color: inherit; +} + +.nx-focal-point-dialog__btn--accent { + background: var(--s2-blue-800, #1473e6); + color: #fff; + border-color: var(--s2-blue-800, #1473e6); +} + +.nx-focal-point-dialog__btn--danger { + border-color: light-dark(#d93c3c, #ff6b6b); + color: light-dark(#b30, #ff9d9d); +} + +.nx-focal-point-dialog .focal-point-content { + --focal-color: #ec4899; + --focal-glow: rgb(236 72 153 / 50%); + + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + padding: 1rem 1.25rem; + box-sizing: border-box; +} + +.nx-focal-point-dialog .focal-point-image-container { + position: relative; + width: 100%; + min-height: 300px; + max-height: 500px; + display: flex; + justify-content: center; + align-items: center; + background: light-dark(#f5f5f5, var(--s2-gray-200, #3d3d3d)); + border: 1px solid light-dark(#ddd, var(--s2-gray-400, #6e6e6e)); + border-radius: 4px; + overflow: hidden; + cursor: grab; +} + +.nx-focal-point-dialog .focal-point-image { + width: 100%; + height: auto; + max-height: 500px; + display: block; + user-select: none; + object-fit: contain; +} + +.nx-focal-point-dialog .focal-point-indicator { + position: absolute; + width: 40px; + height: 40px; + margin-left: -20px; + margin-top: -20px; + pointer-events: none; + z-index: 100; +} + +.nx-focal-point-dialog .focal-point-inner { + position: relative; + width: 100%; + height: 100%; + border-radius: 50%; + background: var(--focal-color); + border: 3px solid white; + box-sizing: border-box; + cursor: grab; + box-shadow: 0 0 0 2px var(--focal-color), 0 4px 12px var(--focal-glow); + animation: nx-focal-pulse 2s infinite; +} + +.nx-focal-point-dialog .focal-point-inner::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 8px; + height: 8px; + background: white; + border-radius: 50%; +} + +@keyframes nx-focal-pulse { + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + + 50% { + transform: scale(1.1); + opacity: 0.9; + } +} + +.nx-focal-point-dialog .focal-point-coords { + display: flex; + gap: 24px; + justify-content: center; + align-items: center; + padding: 16px 24px; + background: light-dark(#fafafa, var(--s2-gray-200, #3d3d3d)); + border-radius: 4px; + border: 1px solid light-dark(#e0e0e0, var(--s2-gray-400, #6e6e6e)); +} + +.nx-focal-point-dialog .focal-point-coords label { + display: flex; + align-items: center; + gap: 12px; + font-size: 14px; + font-weight: 600; + color: light-dark(#555, var(--s2-gray-700, #cacaca)); + min-width: 120px; +} + +.nx-focal-point-dialog .focal-point-input { + width: 90px; + padding: 8px 12px; + border: 1px solid light-dark(#d0d0d0, var(--s2-gray-500, #5c5c5c)); + border-radius: 4px; + font-size: 15px; + text-align: center; + background: light-dark(#fafafa, var(--s2-gray-75, #2c2c2c)); + font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; + color: inherit; + font-weight: 500; + box-shadow: 0 1px 2px rgb(0 0 0 / 5%); +} diff --git a/blocks/canvas/ew-editor-doc/ew-editor-doc.js b/blocks/canvas/ew-editor-doc/ew-editor-doc.js new file mode 100644 index 00000000..0f02fea0 --- /dev/null +++ b/blocks/canvas/ew-editor-doc/ew-editor-doc.js @@ -0,0 +1,318 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { yUndo, yRedo, NodeSelection } from 'da-y-wrapper'; +import { getNx } from '../../../scripts/utils.js'; +import { + updateDocument, updateCursors, getInstrumentedHTML, + editorHtmlChange, editorSelectChange, getEditor, +} from '../editor-utils/editor-utils.js'; +import { getActiveBlockIndex, getBlockPositions } from '../editor-utils/blocks.js'; +import { + editorDocCanLoad, + sourceUrlFromEditorCtx, + controllerPathnameFromEditorCtx, + editorDocRenderPhase, +} from './utils/ctx.js'; +import { subscribeCollabUserList } from './utils/awareness-users.js'; +import { + prefetchWysiwygCookiesIfSignedIn, + wireQuickEditControllerPort, +} from './utils/quick-edit-host.js'; +import { initIms as loadIms } from '../../shared/utils.js'; +import initProse from './prose.js'; +import { createTrackingPlugin } from '../editor-utils/prose-diff.js'; +import { resolveEditorDocSession } from './utils/load-editor-doc.js'; +import { afterNextPaint, ensureProseMountedInShadow } from './utils/shadow-mount.js'; +import { teardownEditorDocResources } from './utils/teardown.js'; +import { hideSelectionToolbar } from '../editor-utils/selection-toolbar.js'; +import { createExtensionsBridgePlugin } from '../editor-utils/extensions-bridge.js'; + +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); + +const style = await loadStyle(import.meta.url); + +export class EwEditorDoc extends LitElement { + static properties = { + ctx: { type: Object }, + quickEditPort: { type: Object }, + _error: { state: true }, + }; + + willUpdate(changed) { + super.willUpdate(changed); + if (changed.has('ctx')) { + this.quickEditPort = undefined; + this._teardown(); + this._error = undefined; + this._lastDocBlockIndex = undefined; + editorHtmlChange.emit(''); + } + } + + _clearControllerPort() { + const port = this._controllerCtx?.port; + if (port) { + port.onmessage = null; + port.close(); + } + this._controllerCtx = undefined; + } + + _emitCollabUsers(users) { + this.dispatchEvent(new CustomEvent('da-collab-users', { + bubbles: true, + composed: true, + detail: { users }, + })); + } + + _emitHtmlChange() { + const { view } = this._proseContext ?? {}; + if (!view) return; + editorHtmlChange.emit(getInstrumentedHTML(view)); + } + + _emitUndoState() { + const mgr = this._proseContext?.undoManager; + const canUndo = mgr ? mgr.undoStack.length > 0 : false; + const canRedo = mgr ? mgr.redoStack.length > 0 : false; + this.dispatchEvent(new CustomEvent('nx-editor-undo-state', { + bubbles: true, + composed: true, + detail: { canUndo, canRedo }, + })); + } + + _observeUndoManager(mgr) { + this._stopObservingUndoManager(); + if (!mgr) return; + this._undoStackHandler = () => this._emitUndoState(); + mgr.on('stack-item-added', this._undoStackHandler); + mgr.on('stack-item-popped', this._undoStackHandler); + } + + _stopObservingUndoManager() { + const mgr = this._proseContext?.undoManager; + if (!mgr || !this._undoStackHandler) return; + mgr.off('stack-item-added', this._undoStackHandler); + mgr.off('stack-item-popped', this._undoStackHandler); + this._undoStackHandler = undefined; + } + + _scrollDocToBlock(blockIndex) { + if (blockIndex < 0) return; + const { view } = this._proseContext ?? {}; + if (!view) return; + const positions = getBlockPositions(view); + const pos = positions[blockIndex]; + if (pos == null) return; + this._lastDocBlockIndex = blockIndex; + const sel = NodeSelection.create(view.state.doc, pos); + view.dispatch(view.state.tr.setSelection(sel).scrollIntoView()); + } + + undo() { + const { view } = this._proseContext ?? {}; + if (view) yUndo(view.state, view.dispatch); + } + + redo() { + const { view } = this._proseContext ?? {}; + if (view) yRedo(view.state, view.dispatch); + } + + _setupController() { + const { view, wsProvider } = this._proseContext ?? {}; + if (!this.quickEditPort || !view || !wsProvider) return; + if (this._controllerCtx?.port === this.quickEditPort) return; + + this._clearControllerPort(); + prefetchWysiwygCookiesIfSignedIn(this.ctx); + + const { org, repo } = this.ctx ?? {}; + this._controllerCtx = { + view, + wsProvider, + port: this.quickEditPort, + iframe: this._wysiwygIframe, + suppressRerender: false, + lastBlockIndex: undefined, + owner: org, + repo, + path: controllerPathnameFromEditorCtx(this.ctx), + getToken: async () => (await loadIms())?.accessToken?.token ?? null, + }; + wireQuickEditControllerPort(this._controllerCtx); + } + + _setupAwareness(wsProvider) { + if (this._awarenessOff) { + this._awarenessOff(); + this._awarenessOff = undefined; + } + this._awarenessOff = subscribeCollabUserList(wsProvider, (users) => { + this._emitCollabUsers(users); + }); + } + + _setEditable(editable) { + this.requestUpdate(); + afterNextPaint(() => { + const pm = this.shadowRoot?.querySelector('.ew-editor-doc-mount .ProseMirror'); + if (pm) pm.contentEditable = editable ? 'true' : 'false'; + }); + } + + _teardown() { + this._stopObservingUndoManager(); + const { wsProvider, view, proseEl } = this._proseContext ?? {}; + teardownEditorDocResources({ + clearPortHandler: () => this._clearControllerPort(), + awarenessOff: this._awarenessOff, + wsProvider, + view, + proseEl, + onCollabUsersCleared: () => this._emitCollabUsers([]), + }); + this._awarenessOff = undefined; + this._proseContext = undefined; + } + + async _loadEditor() { + if (!editorDocCanLoad(this.ctx)) { + return; + } + + const sourceUrl = sourceUrlFromEditorCtx(this.ctx); + + const session = await resolveEditorDocSession(sourceUrl); + if (!session.ok) { + this._error = session.error; + return; + } + + try { + const { token, permissions } = session; + const { proseEl, wsProvider, view, ydoc, undoManager } = await initProse({ + path: sourceUrl, + permissions, + setEditable: (editable) => this._setEditable(editable), + getToken: () => token, + extraPlugins: [ + createExtensionsBridgePlugin(), + createTrackingPlugin( + () => { + const body = this._controllerCtx + ? updateDocument(this._controllerCtx) + : getInstrumentedHTML(this._proseContext?.view); + if (body) editorHtmlChange.emit(body); + }, + () => { if (this._controllerCtx) updateCursors(this._controllerCtx); }, + (data) => { if (this._controllerCtx) getEditor(data, this._controllerCtx); }, + (pmView) => { + const blockIndex = getActiveBlockIndex(pmView); + if (blockIndex === this._lastDocBlockIndex) return; + this._lastDocBlockIndex = blockIndex; + const explicit = pmView.state.selection instanceof NodeSelection; + editorSelectChange.emit({ blockIndex, source: 'doc', explicit }); + }, + ), + ], + }); + + this._proseContext = { proseEl, wsProvider, view, ydoc, undoManager }; + this._setupAwareness(wsProvider); + this._observeUndoManager(undoManager); + this._emitHtmlChange(); + + this._setupController(); + } catch (e) { + this._error = e?.message || 'Failed to load editor'; + this._proseContext = undefined; + return; + } + + this.requestUpdate(); + } + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + this._onCanvasEditorActive = (e) => { + const view = e.detail?.view; + this.hidden = view === 'layout'; + hideSelectionToolbar(); + }; + this.parentElement?.addEventListener('nx-canvas-editor-active', this._onCanvasEditorActive); + this._onWysiwygPortReady = (e) => { + const { port, iframe } = e.detail ?? {}; + if (port) { + this._wysiwygIframe = iframe; + this.quickEditPort = port; + } + }; + this.parentElement?.addEventListener('nx-wysiwyg-port-ready', this._onWysiwygPortReady); + this._unsubscribeSelect = editorSelectChange + .subscribe(({ blockIndex, source }) => { + if (source !== 'doc') this._scrollDocToBlock(blockIndex); + }); + } + + disconnectedCallback() { + this.parentElement?.removeEventListener('nx-canvas-editor-active', this._onCanvasEditorActive); + this.parentElement?.removeEventListener('nx-wysiwyg-port-ready', this._onWysiwygPortReady); + this._unsubscribeSelect?.(); + this._teardown(); + super.disconnectedCallback(); + } + + updated(changed) { + super.updated(changed); + if (changed.has('ctx')) { + this._loadEditor(); + } + if (changed.has('quickEditPort')) { + if (this.quickEditPort && this._proseContext?.view) { + this._setupController(); + } else if (!this.quickEditPort) { + this._clearControllerPort(); + } + } + const { proseEl } = this._proseContext ?? {}; + if (proseEl) { + ensureProseMountedInShadow({ shadowRoot: this.shadowRoot, proseEl }); + } + } + + render() { + const phase = editorDocRenderPhase(this.ctx, { + error: this._error, + hasEditorView: Boolean(this._proseContext?.view), + }); + if (phase === 'incomplete') { + return html` +
+
+ Set hash to #/org/site and open an HTML file to edit. +
+
+ `; + } + if (phase === 'error') { + return html` +
+
${this._error}
+
+ `; + } + if (phase === 'loading') { + return nothing; + } + return html` +
+
+
+ `; + } +} + +customElements.define('ew-editor-doc', EwEditorDoc); diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js b/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js new file mode 100644 index 00000000..c21820ed --- /dev/null +++ b/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js @@ -0,0 +1,84 @@ +import { Plugin } from 'da-y-wrapper'; +import { getNx } from '../../../../scripts/utils.js'; +import { daFetch } from '../../../shared/utils.js'; +import { getSourceUploadContext } from './sourceUploadContext.js'; + +const { DA_ADMIN, DA_CONTENT } = await import(`${getNx()}/utils/utils.js`); + +const FPO_IMG_URL = '/blocks/edit/img/fpo.svg'; + +function makeHash(string) { + return Math.abs(string.split('').reduce((hash, char) => ( + // eslint-disable-next-line no-bitwise -- same hash as da.live paste uploader + char.charCodeAt(0) + (hash << 6) + (hash << 16) - hash + ), 0)); +} + +/** + * @param {{ + * getSourceUrl: () => string | null, + * getEditorView: () => import('prosemirror-view').EditorView | null, + * }} opts + */ +export default function base64Uploader({ getSourceUrl, getEditorView }) { + return new Plugin({ + props: { + transformPastedHTML: (html) => { + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const dataImgs = [...doc.querySelectorAll('[src^="data:image"]')]; + if (!dataImgs.length) { + return html; + } + + const details = getSourceUploadContext(getSourceUrl() ?? ''); + if (!details) return html; + + const imagePaths = []; + const uploadPromises = []; + + dataImgs.forEach((img) => { + const src = img.getAttribute('src'); + let ext = src.replace('data:image/', '').split(';base64')[0]; + if (ext === 'jpeg') ext = 'jpg'; + const path = `${details.parent}/.${details.name}/wp${makeHash(src)}.${ext}`; + const fpoSrc = `${FPO_IMG_URL}#${DA_CONTENT}${path}`; + img.setAttribute('src', fpoSrc); + imagePaths.push(fpoSrc); + + uploadPromises.push((async () => { + const resp = await fetch(src); + const blob = await resp.blob(); + const body = new FormData(); + body.append('data', blob); + await daFetch(`${DA_ADMIN}/source${path}`, { body, method: 'POST' }); + })()); + }); + + Promise.all(uploadPromises).then(() => { + const view = getEditorView(); + if (!view) return; + const { tr } = view.state; + + view.state.doc.descendants((node, pos) => { + if (node.type.name === 'image' && imagePaths.includes(node.attrs.src)) { + const newAttrs = { src: node.attrs.src.split('#')[1] }; + tr.setNodeMarkup(pos, null, { ...node.attrs, ...newAttrs }); + } + }); + + view.dispatch(tr); + }); + + const serializer = new XMLSerializer(); + return serializer.serializeToString(doc); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error handling Base64 images:', error); + return html; + } + }, + }, + }); +} diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js b/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js new file mode 100644 index 00000000..07d9fb86 --- /dev/null +++ b/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js @@ -0,0 +1,57 @@ +import { Plugin, TextSelection } from 'da-y-wrapper'; +import { daFetch } from '../../../shared/utils.js'; +import { getSourceUploadContext } from './sourceUploadContext.js'; + +const FPO_IMG_URL = '/blocks/edit/img/fpo.svg'; +const SUPPORTED_FILES = ['image/svg+xml', 'image/png', 'image/jpeg', 'image/gif']; + +/** + * @param {import('prosemirror-model').Schema} schema + * @param {() => string | null} getSourceUrl + */ +export default function imageDrop(schema, getSourceUrl) { + return new Plugin({ + props: { + handleDOMEvents: { + drop: (view, event) => { + event.preventDefault(); + + const { files } = event.dataTransfer; + if (files.length === 0) return false; + + const details = getSourceUploadContext(getSourceUrl() ?? ''); + if (!details) return false; + + ([...files]).forEach(async (file) => { + if (!SUPPORTED_FILES.some((type) => type === file.type)) return; + + const fpo = schema.nodes.image.create({ src: FPO_IMG_URL, style: 'width: 180px' }); + view.dispatch(view.state.tr.replaceSelectionWith(fpo).scrollIntoView()); + + const { $from } = view.state.selection; + + const url = `${details.origin}/source${details.parent}/.${details.name}/${file.name}`; + + const formData = new FormData(); + formData.append('data', file); + const opts = { method: 'PUT', body: formData }; + const resp = await daFetch(url, opts); + if (!resp.ok) return; + const json = await resp.json(); + + const docImg = document.createElement('img'); + docImg.addEventListener('load', () => { + const fpoSelection = TextSelection.create(view.state.doc, $from.pos - 1, $from.pos); + const ts = view.state.tr.setSelection(fpoSelection); + const img = schema.nodes.image.create({ src: json.source.contentUrl }); + const tr = ts.replaceSelectionWith(img).scrollIntoView(); + view.dispatch(tr); + }); + docImg.src = json.source.contentUrl; + }); + return true; + }, + }, + }, + }); +} diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js b/blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js new file mode 100644 index 00000000..085a1dc2 --- /dev/null +++ b/blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js @@ -0,0 +1,31 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * Derives upload parent/name from a DA source document URL (same shape as da.live getPathDetails). + */ +import { getNx } from '../../../../scripts/utils.js'; + +const { DA_ADMIN } = await import(`${getNx()}/utils/utils.js`); + +/** + * @param {string} sourceUrl - e.g. https://admin.da.live/source/org/repo/path/doc.html + * @returns {{ origin: string, parent: string, name: string } | null} + */ +export function getSourceUploadContext(sourceUrl) { + if (!sourceUrl || typeof sourceUrl !== 'string') return null; + try { + const u = new URL(sourceUrl); + const mark = '/source/'; + const idx = u.pathname.indexOf(mark); + if (idx === -1) return null; + const rest = u.pathname.slice(idx + mark.length); + const segments = rest.split('/').filter(Boolean); + if (segments.length === 0) return null; + const lastSeg = segments[segments.length - 1]; + const name = lastSeg.replace(/\.html?$/i, ''); + const parentSegments = segments.slice(0, -1); + const parent = parentSegments.length ? `/${parentSegments.join('/')}` : '/'; + return { origin: DA_ADMIN, parent, name }; + } catch { + return null; + } +} diff --git a/blocks/canvas/ew-editor-doc/prose.js b/blocks/canvas/ew-editor-doc/prose.js new file mode 100644 index 00000000..03e318e8 --- /dev/null +++ b/blocks/canvas/ew-editor-doc/prose.js @@ -0,0 +1,173 @@ +/* eslint-disable import/no-unresolved -- importmap + da.live prose plugins */ +import { + EditorState, + EditorView, + fixTables, + keymap, + baseKeymap, + Y, + WebsocketProvider, + ySyncPlugin, + yCursorPlugin, + yUndoPlugin, + yUndoPluginKey, + yUndo, + yRedo, + buildKeymap, + tableEditing, + columnResizing, + gapCursor, + liftListItem, + sinkListItem, +} from 'da-y-wrapper'; +import { getSchema } from 'da-parser'; +import { + getEnterInputRulesPlugin, + getURLInputRulesPlugin, + getListInputRulesPlugin, + handleTableBackspace, + handleTableTab, +} from '../../edit/prose/plugins/keyHandlers.js'; +import { getHeadingKeymap } from '../../edit/prose/plugins/menu/menu.js'; +import { createSlashMenuPlugin } from './slash-menu/slash-menu.js'; +import { createSelectionToolbarPlugin } from '../editor-utils/selection-toolbar.js'; +import codemark from '../../edit/prose/plugins/codemark.js'; +import tableSelectHandle from '../../edit/prose/plugins/tableSelectHandle.js'; +import imageDrop from './prose-plugins/imageDrop.js'; +import imageFocalPoint from '../../edit/prose/plugins/imageFocalPoint.js'; +import sectionPasteHandler from '../../edit/prose/plugins/sectionPasteHandler.js'; +import base64Uploader from './prose-plugins/base64Uploader.js'; +import { getNx } from '../../../scripts/utils.js'; +import { generateColor, getCollabIdentity } from './utils/collab.js'; + +const { DA_ADMIN, DA_COLLAB } = await import(`${getNx()}/utils/utils.js`); + +function registerErrorHandler(ydoc) { + ydoc.on('update', () => { + const errorMap = ydoc.getMap('error'); + if (errorMap && errorMap.size > 0) { + // eslint-disable-next-line no-console + console.log('Error from server', JSON.stringify(errorMap)); + errorMap.clear(); + } + }); +} + +function addSyncedListener(wsProvider, canWrite, setEditable) { + const handleSynced = (isSynced) => { + if (isSynced) { + if (canWrite && typeof setEditable === 'function') { + setEditable(true); + } + wsProvider.off('synced', handleSynced); + } + }; + wsProvider.on('synced', handleSynced); +} + +export default async function initProse({ + path, permissions, setEditable, getToken, + extraPlugins = [], +}) { + const editor = document.createElement('div'); + editor.className = 'da-prose-mirror'; + editor.setAttribute('data-gramm', 'false'); + editor.setAttribute('data-gramm_editor', 'false'); + + const schema = getSchema(); + const ydoc = new Y.Doc(); + + const server = DA_COLLAB; + const roomName = `${DA_ADMIN}${new URL(path).pathname}`; + + const wsOpts = { protocols: ['yjs'] }; + if (typeof getToken === 'function') { + const t = getToken(); + if (t) wsOpts.params = { Authorization: `Bearer ${t}` }; + } + + const canWrite = permissions.some((permission) => permission === 'write'); + + const wsProvider = new WebsocketProvider(server, roomName, ydoc, wsOpts); + wsProvider.maxBackoffTime = 30000; + + addSyncedListener(wsProvider, canWrite, setEditable); + registerErrorHandler(ydoc); + + const yXmlFragment = ydoc.getXmlFragment('prosemirror'); + + const identity = await getCollabIdentity(); + if (typeof getToken === 'function' && getToken() && identity) { + wsProvider.awareness.setLocalStateField('user', { + color: generateColor(identity.colorSeed), + name: identity.name, + id: identity.id, + }); + } else { + wsProvider.awareness.setLocalStateField('user', { + color: generateColor(`${wsProvider.awareness.clientID}`), + name: 'Anonymous', + id: `anonymous-${wsProvider.awareness.clientID}`, + }); + } + + /** @type {import('prosemirror-view').EditorView | null} */ + let viewRef = null; + const dispatch = (tr) => { if (viewRef) viewRef.dispatch(tr); }; + + /* Keymap order matches da.live prose/index.js: baseKeymap after buildKeymap + + * handleTableBackspace (fixes list Enter + table NodeSelection + Backspace). */ + const plugins = [ + ySyncPlugin(yXmlFragment), + yCursorPlugin(wsProvider.awareness), + yUndoPlugin(), + tableSelectHandle(), + imageDrop(schema, () => path), + sectionPasteHandler(schema), + base64Uploader({ getSourceUrl: () => path, getEditorView: () => viewRef }), + columnResizing(), + getEnterInputRulesPlugin(dispatch), + getURLInputRulesPlugin(), + getListInputRulesPlugin(schema), + keymap(buildKeymap(schema)), + keymap({ Backspace: handleTableBackspace }), + keymap(baseKeymap), + codemark(), + keymap({ + 'Mod-z': (state) => yUndo(state) || false, + 'Mod-y': (state) => yRedo(state) || false, + 'Mod-Shift-z': (state) => yRedo(state) || false, + ...getHeadingKeymap(schema), + }), + keymap({ + Tab: handleTableTab(1), + 'Shift-Tab': handleTableTab(-1), + }), + keymap({ + Tab: sinkListItem(schema.nodes.list_item), + 'Shift-Tab': liftListItem(schema.nodes.list_item), + }), + gapCursor(), + tableEditing({ allowTableNodeSelection: true }), + ...extraPlugins, + ]; + + if (canWrite) { + plugins.unshift(createSlashMenuPlugin(), createSelectionToolbarPlugin()); + plugins.push(imageFocalPoint()); + } + + let state = EditorState.create({ schema, plugins }); + + const fix = fixTables(state); + if (fix) state = state.apply(fix.setMeta('addToHistory', false)); + + viewRef = new EditorView(editor, { + state, + editable() { return canWrite; }, + }); + + const undoManager = yUndoPluginKey.getState(viewRef.state)?.undoManager ?? null; + + return { proseEl: editor, wsProvider, view: viewRef, ydoc, undoManager }; +} diff --git a/blocks/canvas/ew-editor-doc/slash-menu/slash-menu.js b/blocks/canvas/ew-editor-doc/slash-menu/slash-menu.js new file mode 100644 index 00000000..a1b44eec --- /dev/null +++ b/blocks/canvas/ew-editor-doc/slash-menu/slash-menu.js @@ -0,0 +1,179 @@ +/* eslint-disable import/no-unresolved -- importmap */ +import { Plugin } from 'da-y-wrapper'; +import { getNx } from '../../../../scripts/utils.js'; +import { slashMenuItemsForQuery, COMMAND_BY_ID } from '../../editor-utils/command-defs.js'; + +await import(`${getNx()}/blocks/shared/menu/menu.js`); + +function inTopLevelParagraph($from) { + if ($from.parent.type.name !== 'paragraph') return false; + if ($from.depth < 1) return false; + return $from.node($from.depth - 1).type.name === 'doc'; +} + +function getSlashContext(state) { + const { $from } = state.selection; + if (!inTopLevelParagraph($from)) return null; + + const paraStart = $from.start(); + const head = state.selection.from; + if (head <= paraStart) return null; + + const prefix = state.doc.textBetween(paraStart, head, '\ufffc', '\ufffc'); + if (!prefix.startsWith('/')) return null; + + const query = prefix.slice(1); + if (/\s/.test(query)) return null; + + return { query, anchorPos: paraStart }; +} + +function shouldShowSlashHint(state) { + const { $from } = state.selection; + return ( + inTopLevelParagraph($from) + && $from.parentOffset === 0 + && $from.parent.content.size === 0 + && !getSlashContext(state) + ); +} + +function setup(container, view) { + const anchor = document.createElement('span'); + anchor.style.cssText = 'position:absolute;width:0;height:0;pointer-events:none'; + container.append(anchor); + + const menu = document.createElement('nx-menu'); + menu.ignoreFocus = true; + menu.scoped = true; + menu.items = slashMenuItemsForQuery(''); + container.append(menu); + + menu.addEventListener('select', (e) => { + const run = COMMAND_BY_ID.get(e.detail.id)?.apply; + const { state } = view; + const slash = getSlashContext(state); + if (slash && run) { + const { anchorPos } = slash; + const head = state.selection.from; + const tr = state.tr.delete(anchorPos, head); + view.dispatch(tr); + run(view); + } + view.focus(); + }); + + const scrollEl = container.closest('.ew-editor-doc'); + const onScroll = () => { if (menu.open) menu.reposition(); }; + scrollEl?.addEventListener('scroll', onScroll, { passive: true }); + + return { menu, anchor, scrollEl, onScroll }; +} + +function positionAnchor(view, anchor, pos) { + const coords = view.coordsAtPos(pos); + const rect = anchor.offsetParent.getBoundingClientRect(); + anchor.style.left = `${coords.left - rect.left}px`; + anchor.style.top = `${coords.bottom - rect.top}px`; +} + +function syncSlashHint(view, ctxRef) { + const container = view.dom.parentElement; + if (!container) return; + + if (!shouldShowSlashHint(view.state)) { + if (ctxRef.hintEl) ctxRef.hintEl.style.display = 'none'; + return; + } + + if (!ctxRef.hintEl) { + const hint = document.createElement('span'); + hint.textContent = 'Tap \'/\' to insert'; + hint.setAttribute('aria-hidden', 'true'); + hint.className = 'da-slash-hint'; + container.append(hint); + ctxRef.hintEl = hint; + } + + const { hintEl } = ctxRef; + const pos = view.state.selection.$from.start(); + const coords = view.coordsAtPos(pos); + const containerRect = container.getBoundingClientRect(); + hintEl.style.left = `${coords.left - containerRect.left + 3}px`; + hintEl.style.top = `${coords.top - containerRect.top}px`; + hintEl.style.display = ''; +} + +function syncSlashUi(view, ctxRef) { + syncSlashHint(view, ctxRef); + + const container = view.dom.parentElement; + if (!container) return; + + const slash = getSlashContext(view.state); + + if (!slash) { + ctxRef.ctx?.menu.close(); + return; + } + + const items = slashMenuItemsForQuery(slash.query); + if (!items.length) { + ctxRef.ctx?.menu.close(); + return; + } + + if (!ctxRef.ctx) ctxRef.ctx = setup(container, view); + const { menu, anchor } = ctxRef.ctx; + positionAnchor(view, anchor, slash.anchorPos); + menu.items = items; + if (!menu.open) { + menu.show({ anchor, placement: 'auto' }); + } +} + +function destroySlashUi(ctxRef) { + ctxRef.hintEl?.remove(); + ctxRef.hintEl = null; + const { ctx } = ctxRef; + if (!ctx) return; + ctx.menu.close(); + ctx.scrollEl?.removeEventListener('scroll', ctx.onScroll); + ctx.anchor.remove(); + ctx.menu.remove(); + ctxRef.ctx = null; +} + +export function createSlashMenuPlugin() { + const ctxRef = {}; + + return new Plugin({ + view(editorView) { + const onKeyDown = () => { + syncSlashUi(editorView, ctxRef); + }; + editorView.dom.addEventListener('keydown', onKeyDown); + + return { + update(editorView_) { + // Paste, collab, pointer, and any transaction not preceded by this DOM keydown path + syncSlashUi(editorView_, ctxRef); + }, + destroy() { + editorView.dom.removeEventListener('keydown', onKeyDown); + destroySlashUi(ctxRef); + }, + }; + }, + props: { + handleKeyDown(view, event) { + const { ctx } = ctxRef; + if (!ctx?.menu.open) return false; + const keys = ['ArrowDown', 'ArrowUp', 'Enter', 'Escape']; + if (!keys.includes(event.key)) return false; + ctx.menu.handleKey(event.key); + return true; + }, + }, + }); +} diff --git a/blocks/canvas/ew-editor-doc/utils/awareness-users.js b/blocks/canvas/ew-editor-doc/utils/awareness-users.js new file mode 100644 index 00000000..17eaf340 --- /dev/null +++ b/blocks/canvas/ew-editor-doc/utils/awareness-users.js @@ -0,0 +1,29 @@ +export function subscribeCollabUserList(wsProvider, onList) { + const users = new Set(); + const dispatch = () => { + const self = wsProvider.awareness.clientID; + const awarenessStates = wsProvider.awareness.getStates(); + const userMap = new Map(); + [...users].forEach((u, i) => { + if (u === self) return; + const userInfo = awarenessStates.get(u)?.user; + if (!userInfo?.name) { + userMap.set(`anonymous-${u}`, 'Anonymous'); + } else { + userMap.set(`${userInfo.id}-${i}`, userInfo.name); + } + }); + onList([...userMap.values()].sort()); + }; + const onUpdate = (delta) => { + delta.added.forEach((u) => users.add(u)); + delta.updated.forEach((u) => users.add(u)); + delta.removed.forEach((u) => users.delete(u)); + dispatch(); + }; + wsProvider.awareness.on('update', onUpdate); + dispatch(); + return () => { + wsProvider.awareness.off('update', onUpdate); + }; +} diff --git a/blocks/canvas/ew-editor-doc/utils/collab.js b/blocks/canvas/ew-editor-doc/utils/collab.js new file mode 100644 index 00000000..985ffc03 --- /dev/null +++ b/blocks/canvas/ew-editor-doc/utils/collab.js @@ -0,0 +1,40 @@ +import { initIms as loadIms } from '../../../shared/utils.js'; + +export function generateColor(name, hRange = [0, 360], sRange = [60, 80], lRange = [40, 60]) { + let hash = 0; + for (let i = 0; i < name.length; i += 1) { + // eslint-disable-next-line no-bitwise + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + hash = Math.abs(hash); + const normalizeHash = (min, max) => Math.floor((hash % (max - min)) + min); + const h = normalizeHash(hRange[0], hRange[1]); + const s = normalizeHash(sRange[0], sRange[1]); + const l = normalizeHash(lRange[0], lRange[1]) / 100; + const a = (s * Math.min(l, 1 - l)) / 100; + const f = (n) => { + const k = (n + h / 30) % 12; + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + return Math.round(255 * color).toString(16).padStart(2, '0'); + }; + return `#${f(0)}${f(8)}${f(4)}`; +} + +export async function getCollabIdentity() { + try { + const ims = await loadIms(); + if (ims?.anonymous) return null; + const name = (ims?.displayName || ims?.name || '').trim(); + const id = ims?.userId || ims?.email || ''; + if (name && id) { + return { + name, + id, + colorSeed: ims?.email || ims?.userId || name, + }; + } + } catch { + /* ignore */ + } + return null; +} diff --git a/blocks/canvas/ew-editor-doc/utils/ctx.js b/blocks/canvas/ew-editor-doc/utils/ctx.js new file mode 100644 index 00000000..975da88e --- /dev/null +++ b/blocks/canvas/ew-editor-doc/utils/ctx.js @@ -0,0 +1,29 @@ +import { buildSourceUrl } from './source.js'; + +export function sourceUrlFromEditorCtx(ctx) { + return buildSourceUrl(ctx?.path); +} + +export function editorCtxHasOrgRepoPath(ctx) { + const { org, repo, path } = ctx ?? {}; + return Boolean(org && repo && path); +} + +export function editorDocCanLoad(ctx) { + return editorCtxHasOrgRepoPath(ctx) && Boolean(sourceUrlFromEditorCtx(ctx)); +} + +export function controllerPathnameFromEditorCtx(ctx) { + const docPath = ctx?.path; + if (!docPath || typeof docPath !== 'string') return '/'; + const segments = docPath.replace(/^\//, '').split('/').filter(Boolean); + const withoutOrgRepo = segments.slice(2).join('/'); + return withoutOrgRepo ? `/${withoutOrgRepo}` : '/'; +} + +export function editorDocRenderPhase(ctx, { error, hasEditorView }) { + if (!editorDocCanLoad(ctx)) return 'incomplete'; + if (error) return 'error'; + if (!hasEditorView) return 'loading'; + return 'editor'; +} diff --git a/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js b/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js new file mode 100644 index 00000000..40a9c8cf --- /dev/null +++ b/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js @@ -0,0 +1,19 @@ +import { checkDoc } from './source.js'; +import { initIms } from '../../../shared/utils.js'; + +export async function resolveEditorDocSession(sourceUrl) { + const ims = await initIms(); + const token = ims?.accessToken?.token ?? null; + if (ims?.anonymous || !token) { + return { ok: false, error: 'Sign in required' }; + } + + const resp = await checkDoc(sourceUrl); + if (!resp.ok && resp.status !== 404) { + const error = resp.status === 401 ? 'Sign in required' : `Failed to load (${resp.status})`; + return { ok: false, error }; + } + + const permissions = resp.permissions || ['read']; + return { ok: true, token, permissions }; +} diff --git a/blocks/canvas/ew-editor-doc/utils/quick-edit-host.js b/blocks/canvas/ew-editor-doc/utils/quick-edit-host.js new file mode 100644 index 00000000..60040b62 --- /dev/null +++ b/blocks/canvas/ew-editor-doc/utils/quick-edit-host.js @@ -0,0 +1,27 @@ +import { createControllerOnMessage } from '../../ew-editor-wysiwyg/quick-edit-controller.js'; +import { getNx } from '../../../../scripts/utils.js'; +import { updateDocument, updateCursors, fetchWysiwygCookie } from '../../editor-utils/editor-utils.js'; + +export function prefetchWysiwygCookiesIfSignedIn(ctx) { + const { org, repo } = ctx ?? {}; + if (!org || !repo) return; + (async () => { + const { loadIms } = await import(`${getNx()}/utils/ims.js`); + const token = (await loadIms())?.accessToken?.token; + if (token) { + await fetchWysiwygCookie({ org, repo, token }).catch(() => {}); + } + })().catch(() => {}); +} + +export function wireQuickEditControllerPort(controllerCtx) { + controllerCtx.port.onmessage = createControllerOnMessage(controllerCtx); + const sendInitialBodyAndCursors = () => { + if (!controllerCtx.port) return; + updateDocument(controllerCtx); + updateCursors(controllerCtx); + }; + requestAnimationFrame(() => { + requestAnimationFrame(sendInitialBodyAndCursors); + }); +} diff --git a/blocks/canvas/ew-editor-doc/utils/shadow-mount.js b/blocks/canvas/ew-editor-doc/utils/shadow-mount.js new file mode 100644 index 00000000..59456c6d --- /dev/null +++ b/blocks/canvas/ew-editor-doc/utils/shadow-mount.js @@ -0,0 +1,19 @@ +export function afterNextPaint(cb) { + Promise.resolve().then(() => requestAnimationFrame(cb)); +} + +export function ensureProseMountedInShadow({ + shadowRoot, + proseEl, + mountSelector = '.ew-editor-doc-mount', +}) { + const mount = shadowRoot?.querySelector(mountSelector); + if (!mount || mount.contains(proseEl)) return; + mount.appendChild(proseEl); + if (shadowRoot && !shadowRoot.createRange) { + shadowRoot.createRange = () => document.createRange(); + } + if (shadowRoot && !shadowRoot.getSelection) { + shadowRoot.getSelection = () => document.getSelection(); + } +} diff --git a/blocks/canvas/ew-editor-doc/utils/source.js b/blocks/canvas/ew-editor-doc/utils/source.js new file mode 100644 index 00000000..b22ccec4 --- /dev/null +++ b/blocks/canvas/ew-editor-doc/utils/source.js @@ -0,0 +1,23 @@ +import { getNx } from '../../../../scripts/utils.js'; +import { daFetch } from '../../../shared/utils.js'; + +const { DA_ADMIN } = await import(`${getNx()}/utils/utils.js`); + +export function buildSourceUrl(path) { + if (!path || typeof path !== 'string') return null; + const trimmed = path.replace(/^\//, '').trim(); + if (!trimmed) return null; + return `${DA_ADMIN}/source/${trimmed}.html`; +} + +export function parsePermissions(resp) { + const hint = resp.headers.get('x-da-child-actions') ?? resp.headers.get('x-da-actions'); + if (hint) resp.permissions = hint.split('=').pop().split(','); + else resp.permissions = ['read', 'write']; + return resp; +} + +export async function checkDoc(sourceUrl) { + const resp = await daFetch(sourceUrl, { method: 'HEAD' }); + return parsePermissions(resp); +} diff --git a/blocks/canvas/ew-editor-doc/utils/teardown.js b/blocks/canvas/ew-editor-doc/utils/teardown.js new file mode 100644 index 00000000..a066b17c --- /dev/null +++ b/blocks/canvas/ew-editor-doc/utils/teardown.js @@ -0,0 +1,23 @@ +export function teardownEditorDocResources({ + clearPortHandler, + awarenessOff, + wsProvider, + view, + proseEl, + onCollabUsersCleared, +}) { + clearPortHandler(); + if (awarenessOff) { + awarenessOff(); + } + if (wsProvider) { + wsProvider.disconnect({ data: 'unmount' }); + } + if (view) { + view.destroy(); + } + if (proseEl?.parentNode) { + proseEl.remove(); + } + onCollabUsersCleared(); +} diff --git a/blocks/canvas/ew-editor-split/ew-editor-split.css b/blocks/canvas/ew-editor-split/ew-editor-split.css new file mode 100644 index 00000000..1fc9a2aa --- /dev/null +++ b/blocks/canvas/ew-editor-split/ew-editor-split.css @@ -0,0 +1,43 @@ +.nx-canvas-editor-mount.nx-canvas-editor-mount-split { + flex-flow: row nowrap; + align-items: stretch; + + --nx-canvas-split-ratio: 0.5; +} + +.nx-canvas-split-gutter { + display: none; + flex: 0 0 2px; + width: 2px; + min-width: 2px; + box-sizing: border-box; + flex-shrink: 0; + background-color: var(--s2-gray-200); + cursor: col-resize; + touch-action: none; + user-select: none; +} + +.nx-canvas-editor-mount-split .nx-canvas-split-gutter { + display: block; +} + +/* Split: preview left, gutter, doc right; ratio = left / (100% − 2px) */ +.nx-canvas-editor-mount-split ew-editor-wysiwyg { + --nx-canvas-wysiwyg-split-width: calc((100% - 2px) * var(--nx-canvas-split-ratio, 0.5)); + + flex: 0 0 var(--nx-canvas-wysiwyg-split-width); + width: var(--nx-canvas-wysiwyg-split-width); + min-width: var(--nx-canvas-wysiwyg-split-width); + max-width: none; + flex-shrink: 0; + max-height: none; +} + +.nx-canvas-editor-mount-split ew-editor-doc { + flex: 1 1 0; + min-width: 0; + width: auto; + max-width: none; + max-height: none; +} diff --git a/blocks/canvas/ew-editor-split/ew-editor-split.js b/blocks/canvas/ew-editor-split/ew-editor-split.js new file mode 100644 index 00000000..c9b49b45 --- /dev/null +++ b/blocks/canvas/ew-editor-split/ew-editor-split.js @@ -0,0 +1,121 @@ +import { getNx } from '../../../scripts/utils.js'; + +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); + +const style = await loadStyle(import.meta.url); +document.adoptedStyleSheets = [...document.adoptedStyleSheets, style]; + +export const SPLIT_RATIO_STORAGE_KEY = 'nx-canvas-split-ratio'; + +const SPLIT_RATIO_MIN = 0.15; +const SPLIT_RATIO_MAX = 0.85; +const SPLIT_GUTTER_PX = 2; + +const SPLIT_MOUNT_CLASS = 'nx-canvas-editor-mount-split'; +const SPLIT_GUTTER_CLASS = 'nx-canvas-split-gutter'; + +function readPersistedSplitRatio() { + try { + const v = Number.parseFloat(sessionStorage.getItem(SPLIT_RATIO_STORAGE_KEY), 10); + if (Number.isFinite(v) && v >= SPLIT_RATIO_MIN && v <= SPLIT_RATIO_MAX) return v; + } catch { + /* ignore */ + } + return 0.5; +} + +function persistSplitRatio(ratio) { + try { + sessionStorage.setItem(SPLIT_RATIO_STORAGE_KEY, String(ratio)); + } catch { + /* ignore */ + } +} + +function clampSplitRatio(ratio) { + return Math.min(SPLIT_RATIO_MAX, Math.max(SPLIT_RATIO_MIN, ratio)); +} + +/** Toggle split row on the mount; seed `--nx-canvas-split-ratio` when entering split mode. */ +export function syncEditorSplitLayout({ mountRoot, view }) { + mountRoot.classList.toggle(SPLIT_MOUNT_CLASS, view === 'split'); + if (view !== 'split') return; + const cur = mountRoot.style.getPropertyValue('--nx-canvas-split-ratio').trim(); + if (!cur) { + mountRoot.style.setProperty('--nx-canvas-split-ratio', String(readPersistedSplitRatio())); + } +} + +export function removeSplitGutter(mountRoot) { + mountRoot.querySelector(`.${SPLIT_GUTTER_CLASS}`)?.remove(); +} + +function ensureSplitGutter(mountRoot) { + let g = mountRoot.querySelector(`.${SPLIT_GUTTER_CLASS}`); + if (!g) { + g = document.createElement('div'); + g.className = SPLIT_GUTTER_CLASS; + g.setAttribute('role', 'separator'); + g.setAttribute('aria-orientation', 'vertical'); + g.setAttribute('aria-label', 'Resize split between preview and editor'); + g.tabIndex = -1; + mountRoot.append(g); + } + return g; +} + +/** WYSIWYG (left), 2px gutter, doc (right) — safe if other nodes exist in the mount. */ +export function finalizeSplitEditorMountOrder(mountRoot) { + const doc = mountRoot.querySelector('ew-editor-doc'); + const wyg = mountRoot.querySelector('ew-editor-wysiwyg'); + if (!doc || !wyg) return; + const g = ensureSplitGutter(mountRoot); + mountRoot.append(wyg); + mountRoot.append(g); + mountRoot.append(doc); +} + +function splitRatioFromPointer(mountRoot, clientX) { + const rect = mountRoot.getBoundingClientRect(); + const inner = rect.width - SPLIT_GUTTER_PX; + if (inner <= 0) return 0.5; + return clampSplitRatio((clientX - rect.left) / inner); +} + +/** Pointer-drag on the split gutter; persists ratio under {@link SPLIT_RATIO_STORAGE_KEY}. */ +export function installEditorSplitDrag(mountRoot) { + if (mountRoot.dataset.nxSplitDragInstalled) return; + mountRoot.dataset.nxSplitDragInstalled = '1'; + + mountRoot.addEventListener('pointerdown', (e) => { + if (!mountRoot.classList.contains(SPLIT_MOUNT_CLASS)) return; + const gutter = e.target?.closest?.(`.${SPLIT_GUTTER_CLASS}`); + if (!gutter || !mountRoot.contains(gutter)) return; + e.preventDefault(); + gutter.setPointerCapture(e.pointerId); + + const onMove = (ev) => { + const ratio = splitRatioFromPointer(mountRoot, ev.clientX); + mountRoot.style.setProperty('--nx-canvas-split-ratio', String(ratio)); + }; + + const onUp = () => { + try { + gutter.releasePointerCapture(e.pointerId); + } catch { + /* ignore if already released */ + } + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + const raw = mountRoot.style.getPropertyValue('--nx-canvas-split-ratio').trim(); + const ratio = clampSplitRatio(Number.parseFloat(raw, 10) || readPersistedSplitRatio()); + mountRoot.style.setProperty('--nx-canvas-split-ratio', String(ratio)); + persistSplitRatio(ratio); + }; + + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + }); +} diff --git a/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.css b/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.css new file mode 100644 index 00000000..9369524c --- /dev/null +++ b/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.css @@ -0,0 +1,37 @@ +:host { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.ew-editor-wysiwyg-surface { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + min-width: 0; +} + +.ew-editor-wysiwyg-surface[hidden] { + display: none !important; +} + +.ew-editor-wysiwyg-iframe { + flex: 1; + width: 100%; + min-height: 0; + border: 0; +} + +.ew-editor-wysiwyg-placeholder { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-height: 120px; + padding: 1rem; + color: light-dark(#555, #aaa); + font: var(--nx-font-body, 14px/1.4 system-ui, sans-serif); + text-align: center; +} diff --git a/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js b/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js new file mode 100644 index 00000000..54d6811c --- /dev/null +++ b/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js @@ -0,0 +1,233 @@ +import { LitElement, html } from 'da-lit'; +import { getNx } from '../../../scripts/utils.js'; +import { getPreviewOrigin, fetchWysiwygCookie } from '../editor-utils/editor-utils.js'; +import { initIms as loadIms } from '../../shared/utils.js'; +import { hideSelectionToolbar } from '../editor-utils/selection-toolbar.js'; + +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); + +const style = await loadStyle(import.meta.url); + +const QUICK_EDIT_INIT_INTERVAL_MS = 400; +const QUICK_EDIT_INIT_MAX_ATTEMPTS = 25; + +const WYSIWYG_PORT_READY_ATTR = 'data-nx-wysiwyg-port-ready'; + +function buildQuickEditInitPayload({ org, repo, path }) { + const pathWithoutOrgRepo = path.split('/').slice(2).join('/'); + const pathname = pathWithoutOrgRepo ? `/${pathWithoutOrgRepo}` : '/'; + return { + config: { mountpoint: `${getPreviewOrigin(org, repo)}/${org}/${repo}` }, + location: { pathname }, + }; +} + +async function tryLoadWysiwygPreviewCookies({ org, repo, path, getCurrentCtx }) { + try { + const token = (await loadIms())?.accessToken?.token; + if (!token) { + // eslint-disable-next-line no-console + console.warn('[ew-editor-wysiwyg] Preview cookies: no auth token, proceeding without cookies'); + } else { + await fetchWysiwygCookie({ org, repo, token }).catch((e) => { + // eslint-disable-next-line no-console + console.warn('[ew-editor-wysiwyg] Preview cookies failed, proceeding without cookies', e); + }); + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[ew-editor-wysiwyg] Preview cookie setup failed, proceeding without cookies', e); + } + const cur = getCurrentCtx(); + return cur?.org === org && cur?.repo === repo && cur?.path === path; +} + +export class EwEditorWysiwyg extends LitElement { + static properties = { + ctx: { type: Object }, + _cookieReady: { state: true }, + _loading: { state: true }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + this._onCanvasEditorActive = (e) => { + this._canvasActiveView = e.detail?.view; + this._syncCanvasVisibility(); + }; + this.parentElement?.addEventListener('nx-canvas-editor-active', this._onCanvasEditorActive); + this._syncCanvasVisibility(); + } + + disconnectedCallback() { + this.parentElement?.removeEventListener('nx-canvas-editor-active', this._onCanvasEditorActive); + this._clearQuickEditRetry(); + super.disconnectedCallback(); + } + + get _iframeSrc() { + const { org, repo, path } = this.ctx ?? {}; + if (!org || !repo || !path || !this._cookieReady) return null; + const segments = path.split('/'); + const pathWithoutOrgRepo = segments.slice(2).join('/'); + const encodedPath = pathWithoutOrgRepo.split('/').map(encodeURIComponent).join('/'); + const quickEdit = new URLSearchParams(window.location.search).get('quick-edit') || 'ew'; + const base = `${getPreviewOrigin(org, repo)}/${encodedPath}?nx=ew&quick-edit=${encodeURIComponent(quickEdit)}`; + return `${base}&controller=parent`; + } + + _disposeQuickEditLocalPort() { + if (!this._quickEditLocalPort) return; + try { + this._quickEditLocalPort.onmessage = null; + this._quickEditLocalPort.close(); + } catch { + /* ignore */ + } + this._quickEditLocalPort = null; + } + + _clearQuickEditRetry() { + if (this._quickEditInitRetryId) { + clearInterval(this._quickEditInitRetryId); + this._quickEditInitRetryId = null; + } + this._disposeQuickEditLocalPort(); + } + + _syncCanvasVisibility() { + const view = this._canvasActiveView ?? 'layout'; + const portReady = this.hasAttribute(WYSIWYG_PORT_READY_ATTR); + const showWysiwyg = view === 'layout' || view === 'split'; + this.hidden = !showWysiwyg; + this._loading = showWysiwyg && !portReady; + hideSelectionToolbar(); + } + + _resetCookieStateForCtxChange() { + this._clearQuickEditRetry(); + this._cookieReady = false; + } + + updated(changed) { + super.updated(changed); + if (!changed.has('ctx')) return; + this.removeAttribute(WYSIWYG_PORT_READY_ATTR); + this._resetCookieStateForCtxChange(); + this._syncCanvasVisibility(); + const { org, repo, path } = this.ctx ?? {}; + if (!org || !repo || !path) return; + + tryLoadWysiwygPreviewCookies({ + org, + repo, + path, + getCurrentCtx: () => this.ctx, + }).then((ok) => { + if (!ok) return; + this._cookieReady = true; + this.requestUpdate(); + }); + } + + _dispatchWysiwygPortReady(port) { + this._clearQuickEditRetry(); + this.setAttribute(WYSIWYG_PORT_READY_ATTR, ''); + this._syncCanvasVisibility(); + const iframe = this.shadowRoot?.querySelector('iframe'); + this.dispatchEvent(new CustomEvent('nx-wysiwyg-port-ready', { + bubbles: true, + composed: true, + detail: { port, iframe }, + })); + } + + _scheduleQuickEditInitRetries(send) { + let attempts = 0; + this._quickEditInitRetryId = setInterval(() => { + attempts += 1; + if (attempts >= QUICK_EDIT_INIT_MAX_ATTEMPTS) { + this._clearQuickEditRetry(); + return; + } + send(); + }, QUICK_EDIT_INIT_INTERVAL_MS); + } + + _postQuickEditInitToIframe({ iframe, config, location, onReady }) { + this._disposeQuickEditLocalPort(); + const { port1, port2 } = new MessageChannel(); + this._quickEditLocalPort = port1; + port1.onmessage = (ev) => { + if (ev.data?.ready !== true) return; + this._quickEditLocalPort = null; + onReady(port1); + }; + try { + const targetOrigin = new URL(iframe.src).origin; + iframe.contentWindow.postMessage({ init: config, location }, targetOrigin, [port2]); + } catch (err) { + this._disposeQuickEditLocalPort(); + // eslint-disable-next-line no-console + console.error('[ew-editor-wysiwyg] Error posting init to iframe', err); + } + } + + _onIframeLoad(e) { + const iframe = e?.target; + const { org, repo, path } = this.ctx ?? {}; + if (!iframe?.contentWindow || !org || !repo || !path) return; + + this.removeAttribute(WYSIWYG_PORT_READY_ATTR); + this._clearQuickEditRetry(); + this._syncCanvasVisibility(); + + const { config, location } = buildQuickEditInitPayload({ org, repo, path }); + const send = () => this._postQuickEditInitToIframe({ + iframe, + config, + location, + onReady: (port) => this._dispatchWysiwygPortReady(port), + }); + + send(); + this._scheduleQuickEditInitRetries(send); + } + + _onIframeBlur() { + hideSelectionToolbar(); + } + + render() { + const { org, repo, path } = this.ctx ?? {}; + const hasPath = org && repo && path; + let body; + if (!hasPath) { + body = html` +
Select an HTML file for WYSIWYG preview.
+ `; + } else if (!this._cookieReady) { + body = html`
Loading preview…
`; + } else { + const src = this._iframeSrc; + body = html` + + `; + } + return html` +
+ ${body} +
+ `; + } +} + +customElements.define('ew-editor-wysiwyg', EwEditorWysiwyg); diff --git a/blocks/canvas/ew-editor-wysiwyg/quick-edit-controller.js b/blocks/canvas/ew-editor-wysiwyg/quick-edit-controller.js new file mode 100644 index 00000000..c8804fe8 --- /dev/null +++ b/blocks/canvas/ew-editor-wysiwyg/quick-edit-controller.js @@ -0,0 +1,29 @@ +import { updateDocument, updateState, getEditor } from '../editor-utils/editor-utils.js'; +import { hideSelectionToolbar } from '../editor-utils/selection-toolbar.js'; +import { handleImageReplace } from './utils/image.js'; +import { + handleCursorMove, + handleUndoRedo, + handleIframeSelectionChange, +} from './utils/handlers.js'; + +export function createControllerOnMessage(ctx) { + return function onMessage(e) { + if (e.data.type === 'cursor-move') { + hideSelectionToolbar(); + handleCursorMove(e.data, ctx); + } else if (e.data.type === 'reload') { + updateDocument(ctx); + } else if (e.data.type === 'image-replace') { + handleImageReplace(e.data, ctx); + } else if (e.data.type === 'get-editor') { + getEditor(e.data, ctx); + } else if (e.data.type === 'node-update') { + updateState(e.data, ctx); + } else if (e.data.type === 'history') { + handleUndoRedo(e.data, ctx); + } else if (e.data.type === 'selection-change') { + handleIframeSelectionChange(e.data, ctx); + } + }; +} diff --git a/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js b/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js new file mode 100644 index 00000000..bcbc4877 --- /dev/null +++ b/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js @@ -0,0 +1,161 @@ +import { TextSelection, yUndo, yRedo } from 'da-y-wrapper'; +import { + getSelectionToolbar, + hideSelectionToolbar, + TOOLBAR_PADDING_GAP, + NX_QUICK_EDIT_IFRAME_SELECTION_META, + NX_QUICK_EDIT_CLEAR_IFRAME_SELECTION_ORIGIN_META, +} from '../../editor-utils/selection-toolbar.js'; +import { editorSelectChange } from '../../editor-utils/editor-utils.js'; +import { getActiveBlockIndex } from '../../editor-utils/blocks.js'; + +export function handleCursorMove({ cursorOffset, textCursorOffset }, ctx) { + const { view, wsProvider } = ctx; + if (!view || !wsProvider) return; + + if (cursorOffset == null || textCursorOffset == null) { + delete view.hasFocus; + wsProvider.awareness.setLocalStateField('cursor', null); + return; + } + + const { state } = view; + const position = cursorOffset + textCursorOffset; + + try { + if (position < 0 || position > state.doc.content.size) { + // eslint-disable-next-line no-console + console.warn('Invalid cursor position:', position); + return; + } + + view.hasFocus = () => true; + + const { tr } = state; + tr.setSelection(TextSelection.create(state.doc, position)); + + // Sync stored marks so the toolbar reflects the marks active at the cursor. + // Two problems this solves: + // 1. ProseMirror clears storedMarks whenever selection.anchor changes, which + // happens on every cursor-move — that wipes toolbar-toggled marks before the + // first keystroke arrives. + // 2. marksAcross() returns Mark.none when the cursor is at the end of a mark + // run (nothing to the right), so the toolbar shows the mark as inactive even + // though the text is marked. nodeBefore/nodeAfter covers both sides. + const $pos = state.doc.resolve(position); + const marksBefore = $pos.nodeBefore?.marks; + const marksAfter = $pos.nodeAfter?.marks; + const marksAtCursor = (marksBefore?.length ? marksBefore : null) + ?? (marksAfter?.length ? marksAfter : null); + + if (marksAtCursor) { + // Cursor is adjacent to marked text — use those marks (handles Cmd+B case). + tr.setStoredMarks(marksAtCursor); + } else if (state.storedMarks?.length) { + // No marked text at this position, but user explicitly toggled a mark via + // the toolbar — preserve it so it survives cursor-move events before typing. + tr.setStoredMarks(state.storedMarks); + } + + ctx.suppressRerender = true; + view.dispatch(tr.scrollIntoView()); + ctx.suppressRerender = false; + const blockIndex = getActiveBlockIndex(view); + if (blockIndex !== ctx.lastBlockIndex) { + ctx.lastBlockIndex = blockIndex; + editorSelectChange.emit({ blockIndex, source: 'wysiwyg' }); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error moving cursor:', error); + } +} + +export function handleUndoRedo(data, ctx) { + const { action } = data; + const view = ctx?.view; + if (!view) return; + if (action === 'undo') { + yUndo(view.state); + } else if (action === 'redo') { + yRedo(view.state); + } +} + +export function handleStoredMarks({ marks }, ctx) { + const { view } = ctx; + if (!view) return; + const { state } = view; + const { schema } = state; + try { + const parsedMarks = marks + .map((m) => { + const markType = schema.marks[m.type]; + return markType ? markType.create(m.attrs) : null; + }) + .filter(Boolean); + const { tr } = state; + tr.setStoredMarks(parsedMarks); + ctx.suppressRerender = true; + view.dispatch(tr); + ctx.suppressRerender = false; + } catch (e) { + // eslint-disable-next-line no-console + console.error('[quick-edit-controller] handleStoredMarks failed', e?.message); + } +} + +export function handleSelectionChange({ anchor, head }, ctx, { fromQuickEditIframe = false } = {}) { + const { view } = ctx; + if (!view) return false; + const { state } = view; + try { + const a = Math.max(0, Math.min(anchor, state.doc.content.size)); + const h = Math.max(0, Math.min(head, state.doc.content.size)); + const { tr } = state; + tr.setSelection(TextSelection.create(state.doc, a, h)); + if (fromQuickEditIframe) tr.setMeta(NX_QUICK_EDIT_IFRAME_SELECTION_META, true); + ctx.suppressRerender = true; + view.dispatch(tr); + ctx.suppressRerender = false; + return true; + } catch (e) { + // eslint-disable-next-line no-console + console.error('[quick-edit-controller] handleSelectionChange failed', e?.message); + return false; + } +} + +function positionSelectionToolbarFromIframe(data, ctx) { + const { view } = ctx; + const { anchorX, anchorY } = data; + const { iframe } = ctx; + if (!iframe) return; + + const iframeRect = iframe.getBoundingClientRect(); + const x = iframeRect.left + anchorX; + const y = iframeRect.top + anchorY - TOOLBAR_PADDING_GAP; + const tb = getSelectionToolbar(); + tb.view = view; + tb.show({ x, y }); +} + +/** PostMessage `selection-change` from wysiwyg iframe: sync PM selection and toolbar. */ +export function handleIframeSelectionChange(data, ctx) { + const { anchor, head } = data; + if (anchor === head) { + hideSelectionToolbar(); + const { view } = ctx; + if (view) { + const tr = view.state.tr + .setMeta(NX_QUICK_EDIT_CLEAR_IFRAME_SELECTION_ORIGIN_META, true) + .setMeta('addToHistory', false); + ctx.suppressRerender = true; + view.dispatch(tr); + ctx.suppressRerender = false; + } + return; + } + if (!handleSelectionChange(data, ctx, { fromQuickEditIframe: true })) return; + positionSelectionToolbarFromIframe(data, ctx); +} diff --git a/blocks/canvas/ew-editor-wysiwyg/utils/image.js b/blocks/canvas/ew-editor-wysiwyg/utils/image.js new file mode 100644 index 00000000..cc392222 --- /dev/null +++ b/blocks/canvas/ew-editor-wysiwyg/utils/image.js @@ -0,0 +1,124 @@ +import { getNx } from '../../../../scripts/utils.js'; + +const { DA_ADMIN, DA_CONTENT } = await import(`${getNx()}/utils/utils.js`); + +function updateImageInDocument(view, originalSrc, newSrc) { + if (!view) return false; + + const { state } = view; + const { tr } = state; + let updated = false; + + state.doc.descendants((node, pos) => { + if (node.type.name === 'image') { + const currentSrc = node.attrs.src; + let isMatch = currentSrc === originalSrc; + + if (!isMatch) { + try { + const currentUrl = new URL(currentSrc, window.location.href); + const originalUrl = new URL(originalSrc, window.location.href); + isMatch = currentUrl.pathname === originalUrl.pathname; + } catch { + isMatch = currentSrc.includes(originalSrc) || originalSrc.includes(currentSrc); + } + } + + if (isMatch) { + const newAttrs = { ...node.attrs, src: newSrc }; + tr.setNodeMarkup(pos, null, newAttrs); + updated = true; + } + } + }); + + if (updated) { + view.dispatch(tr); + } + + return updated; +} + +function dataUrlToBlob(dataUrl) { + const [header, base64Data] = dataUrl.split(','); + const mimeMatch = header.match(/:(.*?);/); + const mimeType = mimeMatch ? mimeMatch[1] : 'application/octet-stream'; + const byteString = atob(base64Data); + const arrayBuffer = new ArrayBuffer(byteString.length); + const uint8Array = new Uint8Array(arrayBuffer); + for (let i = 0; i < byteString.length; i += 1) { + uint8Array[i] = byteString.charCodeAt(i); + } + return new Blob([uint8Array], { type: mimeType }); +} + +function getPageName(currentPath) { + if (currentPath.endsWith('/')) return `${currentPath.replace(/^\//, '')}index`; + return currentPath.replace(/^\//, ''); +} + +export async function handleImageReplace({ imageData, fileName, originalSrc }, ctx) { + ctx.suppressRerender = true; + + try { + // eslint-disable-next-line no-console + console.log('handleImageReplace', fileName, originalSrc); + + const blob = dataUrlToBlob(imageData); + + const pageName = getPageName(ctx.path); + const parentPath = ctx.path === '/' ? '' : ctx.path.replace(/\/[^/]+$/, ''); + + // Same upload path and URL as da-nx quick-edit-portal/src/images.js + const uploadPath = `${parentPath}/.${pageName}/${fileName}`; + const uploadUrl = `${DA_ADMIN}/source/${ctx.owner}/${ctx.repo}${uploadPath}`; + + const tokenPromise = typeof ctx.getToken === 'function' ? ctx.getToken() : null; + const token = tokenPromise != null && typeof tokenPromise?.then === 'function' + ? await tokenPromise + : tokenPromise; + const headers = {}; + if (token) headers.Authorization = `Bearer ${token}`; + + const formData = new FormData(); + formData.append('data', blob, fileName); + + const resp = await fetch(uploadUrl, { + method: 'PUT', + body: formData, + headers, + }); + + if (!resp.ok) { + ctx.port.postMessage({ + type: 'image-error', + error: `Upload failed with status ${resp.status}`, + originalSrc, + }); + return; + } + + // Same as da-nx: AEM delivery URL for the uploaded image + const newSrc = `${DA_CONTENT}/${ctx.owner}/${ctx.repo}${uploadPath}`; + + updateImageInDocument(ctx.view, originalSrc, newSrc); + + ctx.port.postMessage({ + type: 'update-image-src', + newSrc, + originalSrc, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error replacing image:', error); + ctx.port.postMessage({ + type: 'image-error', + error: error.message, + originalSrc, + }); + } finally { + setTimeout(() => { + ctx.suppressRerender = false; + }, 500); + } +} diff --git a/blocks/canvas/ew-file-explorer/ew-file-explorer.css b/blocks/canvas/ew-file-explorer/ew-file-explorer.css new file mode 100644 index 00000000..0c5f6887 --- /dev/null +++ b/blocks/canvas/ew-file-explorer/ew-file-explorer.css @@ -0,0 +1,106 @@ +:host { + display: block; + height: 100%; + min-height: 0; + font-family: var(--s2-font-family); +} + +.ew-file-explorer { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.tree { + flex: 1; + overflow-y: auto; + list-style: none; + margin: 0; + padding: var(--s2-spacing-75) 0; +} + +[role="group"] { + list-style: none; + margin: 0; + padding: 0; +} + +.row { + display: flex; + align-items: center; + width: 100%; + min-height: 32px; + padding: var(--s2-spacing-75) var(--s2-spacing-100); + padding-inline-start: calc(var(--depth, 0) * var(--s2-spacing-300) + var(--s2-spacing-100)); + border: none; + background: transparent; + color: var(--s2-gray-800); + font-family: inherit; + font-size: var(--s2-body-size-s); + font-weight: 500; + text-align: start; + cursor: pointer; + gap: var(--s2-spacing-75); + box-sizing: border-box; + + &:hover { + background: var(--s2-gray-75); + } + + &.selected { + background: var(--s2-blue-100); + color: var(--s2-blue-900); + } + + &:focus-visible { + outline: 2px solid var(--s2-blue-700); + outline-offset: -2px; + border-radius: var(--s2-corner-radius-75); + } +} + +.row[aria-expanded]::before { + content: '›'; + display: inline-block; + flex-shrink: 0; + width: 1rem; + text-align: center; + transition: transform 0.15s; +} + +.row[aria-expanded="true"]::before { + transform: rotate(90deg); +} + +/* Files have no chevron — indent to align with folder labels */ +.row.file { + padding-inline-start: calc(var(--depth, 0) * var(--s2-spacing-300) + var(--s2-spacing-100) + 1rem + var(--s2-spacing-75)); +} + +.label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.notice { + padding: var(--s2-spacing-75) var(--s2-spacing-100); + font-size: var(--s2-body-size-s); + color: var(--s2-gray-600); + margin: 0; + + &.error { + color: var(--s2-red-700); + } +} + +.placeholder { + padding: var(--s2-spacing-300) var(--s2-spacing-100); + font-size: var(--s2-body-size-s); + color: var(--s2-gray-600); + text-align: center; + margin: 0; +} diff --git a/blocks/canvas/ew-file-explorer/ew-file-explorer.js b/blocks/canvas/ew-file-explorer/ew-file-explorer.js new file mode 100644 index 00000000..87dad725 --- /dev/null +++ b/blocks/canvas/ew-file-explorer/ew-file-explorer.js @@ -0,0 +1,184 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { getNx } from '../../../scripts/utils.js'; +import { listFolder, itemHashPath } from '../../shared/daFiles.js'; +import { treeKeydown, treeFocusIn, treeEnsureTabStop } from '../utils/tree-nav.js'; + +const { loadStyle, hashChange } = await import(`${getNx()}/utils/utils.js`); + +const style = await loadStyle(import.meta.url); + +function listItemToNode(item, cache) { + const pathKey = (item.path || '').replace(/^\//, ''); + const fullpath = `/${pathKey}`; + const isDir = !item.ext; + return { + name: item.name, + type: isDir ? 'directory' : 'file', + path: fullpath, + pathKey, + ext: item.ext, + children: isDir && cache[fullpath] + ? cache[fullpath].map((child) => listItemToNode(child, cache)) + : [], + }; +} + +function buildTree(cache, rootFullpath) { + const pathKey = rootFullpath.replace(/^\//, ''); + const items = cache[rootFullpath]; + return [{ + name: pathKey.split('/').pop(), + type: 'directory', + path: rootFullpath, + pathKey, + children: items ? items.map((item) => listItemToNode(item, cache)) : [], + }]; +} + +class EwFileExplorer extends LitElement { + static properties = { + _cache: { state: true }, + _loading: { state: true }, + _error: { state: true }, + _expanded: { state: true }, + _selectedPath: { state: true }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + this._unsubHash = hashChange.subscribe((state) => this._onHashChange(state)); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._unsubHash?.(); + } + + updated() { + treeEnsureTabStop(this.shadowRoot); + } + + _onHashChange({ org, site, path }) { + const rootChanged = org !== this._org || site !== this._site; + this._org = org; + this._site = site; + + if (!org || !site) { + this._cache = {}; + this._expanded = new Set(); + this._selectedPath = undefined; + this._error = null; + return; + } + + this._selectedPath = path ? `${org}/${site}/${path}` : undefined; + + if (rootChanged) { + this._cache = {}; + this._expanded = new Set([`${org}/${site}`]); + this._loadFromRoot(`/${org}/${site}`, org, site, path); + } + } + + async _loadFromRoot(rootFullpath, org, site, path) { + this._loading = true; + this._error = null; + const cache = {}; + const orgSite = `${org}/${site}`; + const expanded = new Set([orgSite]); + const toFetch = [rootFullpath]; + + if (path) { + const parts = path.split('/'); + for (let i = 1; i < parts.length; i += 1) { + const ancestorPath = `/${orgSite}/${parts.slice(0, i).join('/')}`; + toFetch.push(ancestorPath); + expanded.add(ancestorPath.replace(/^\//, '')); + } + } + + try { + await Promise.all(toFetch.map(async (fp) => { + const result = await listFolder(fp); + if (Array.isArray(result)) cache[fp] = result; + else if (fp === rootFullpath) this._error = result.error; + })); + this._cache = cache; + this._expanded = expanded; + } finally { + this._loading = false; + } + } + + async _loadAndExpand(pathKey) { + this._loading = true; + const result = await listFolder(`/${pathKey}`); + if (!Array.isArray(result)) { + this._error = result.error; + } else { + this._cache = { ...this._cache, [`/${pathKey}`]: result }; + this._expanded = new Set([...(this._expanded ?? []), pathKey]); + } + this._loading = false; + } + + _toggle(pathKey, path) { + if (!this._cache?.[path]) { + this._loadAndExpand(pathKey); + return; + } + const next = new Set(this._expanded); + if (next.has(pathKey)) next.delete(pathKey); + else next.add(pathKey); + this._expanded = next; + } + + _renderNode(item, depth) { + const { type, pathKey, name, children, path } = item; + const isDir = type === 'directory'; + const expanded = isDir && this._expanded?.has(pathKey); + const hashPath = itemHashPath(item); + const selected = this._selectedPath === hashPath; + + return html` +
  • + + ${expanded && children.length ? html` +
      + ${children.map((c) => this._renderNode(c, depth + 1))} +
    ` : nothing} +
  • `; + } + + render() { + if (!this._org || !this._site) { + return html`
    +

    Select a site to browse files.

    +
    `; + } + + const tree = buildTree(this._cache ?? {}, `/${this._org}/${this._site}`); + + return html`
    + ${this._error ? html`` : nothing} + ${this._loading && !Object.keys(this._cache ?? {}).length + ? html`

    Loading…

    ` : nothing} +
      + ${tree.map((item) => this._renderNode(item, 0))} +
    +
    `; + } +} + +customElements.define('ew-file-explorer', EwFileExplorer); diff --git a/blocks/canvas/ew-page-outline/ew-page-outline.css b/blocks/canvas/ew-page-outline/ew-page-outline.css new file mode 100644 index 00000000..650bff26 --- /dev/null +++ b/blocks/canvas/ew-page-outline/ew-page-outline.css @@ -0,0 +1,135 @@ +:host { + display: block; + height: 100%; + min-height: 0; + font-family: var(--s2-font-family); +} + +.ew-page-outline { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + margin: 0; +} + +.list-wrap { + flex: 1; + overflow-y: auto; + padding: var(--s2-spacing-75) 0; +} + +.outline-list { + list-style: none; + margin: 0; + padding: 0; +} + +.outline-section { + margin: 0; + padding: 0; + border-bottom: 1px solid var(--s2-gray-200); + + &:last-child:not([data-drop-position="after"]) { + border-bottom: none; + } +} + +.section-header { + background: var(--s2-gray-75); + cursor: grab; + + &:active { + cursor: grabbing; + } +} + +.section-label { + display: block; + padding: var(--s2-spacing-200) var(--s2-spacing-100); + font-size: var(--s2-body-size-s); + font-weight: 600; + color: var(--s2-gray-800); +} + +.block-list { + list-style: none; + margin: 0; + padding: 0; +} + +.block-item { + display: block; + padding: var(--s2-spacing-75) var(--s2-spacing-100); + padding-inline-start: var(--s2-spacing-300); + cursor: grab; + font-size: var(--s2-body-size-s); + font-weight: 500; + color: var(--s2-gray-800); + min-height: 32px; + box-sizing: border-box; + border-bottom: 1px solid var(--s2-gray-100); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:hover { + background: var(--s2-gray-75); + } + + &:focus-visible { + background: var(--s2-gray-75); + } + + &:last-child:not([data-drop-position="after"]) { + border-bottom: none; + } + + &[aria-selected="true"] { + background: var(--s2-blue-100); + color: var(--s2-blue-900); + } +} + +[role="treeitem"]:focus-visible { + outline: 2px solid var(--s2-blue-700); + outline-offset: -2px; + border-radius: var(--s2-corner-radius-75); +} + +.dragging { + opacity: 0.4; + cursor: grabbing; +} + +[data-drop-position="before"] { + border-top: 2px solid var(--s2-blue-600, #147af3); +} + +[data-drop-position="after"] { + border-bottom: 2px solid var(--s2-blue-600, #147af3); +} + +.block-empty { + cursor: default; +} + +.empty-label { + font-style: italic; + color: var(--s2-gray-600); + font-weight: 400; +} + +.placeholder { + padding: var(--s2-spacing-300) var(--s2-spacing-100); + font-size: var(--s2-body-size-s); + color: var(--s2-gray-600); + text-align: center; + + code { + font-family: ui-monospace, monospace; + background: var(--s2-gray-100); + padding: 0.1em 0.3em; + border-radius: var(--s2-corner-radius-75); + } +} diff --git a/blocks/canvas/ew-page-outline/ew-page-outline.js b/blocks/canvas/ew-page-outline/ew-page-outline.js new file mode 100644 index 00000000..8753e398 --- /dev/null +++ b/blocks/canvas/ew-page-outline/ew-page-outline.js @@ -0,0 +1,230 @@ +import { LitElement, html } from 'da-lit'; +import { getNx } from '../../../scripts/utils.js'; +import { treeKeydown } from '../utils/tree-nav.js'; +import { editorHtmlChange, editorSelectChange, parseSections } from '../editor-utils/editor-utils.js'; +import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js'; +import { moveBlock, moveSection } from '../editor-utils/blocks.js'; + +const { loadStyle, HashController } = await import(`${getNx()}/utils/utils.js`); + +const style = await loadStyle(import.meta.url); + +const OUTLINE_TYPES = { + SECTION: 'section', + BLOCK: 'block', +}; + +const DROP_POSITIONS = { + BEFORE: 'before', + AFTER: 'after', +}; + +function sectionsEqual(a, b) { + if (!a || !b || a.length !== b.length) return false; + return a.every((sec, i) => { + const other = b[i]; + return sec.sectionIndex === other.sectionIndex + && sec.blocks.length === other.blocks.length + && sec.blocks.every((blk, j) => blk.name === other.blocks[j].name); + }); +} + +class EwPageOutline extends LitElement { + static properties = { + _sections: { state: true }, + _selectedBlockIndex: { state: true }, + }; + + _hashCtrl = new HashController(this); + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + this._unsubscribeHtml = editorHtmlChange.subscribe((aemHtml) => { + if (aemHtml.trim()) { + const next = parseSections(aemHtml); + if (!sectionsEqual(next, this._sections)) this._sections = next; + } else { + this._sections = undefined; + this._selectedBlockIndex = undefined; + } + }); + this._unsubscribeSelect = editorSelectChange + .subscribe(({ blockIndex, source }) => { + if (source === 'outline') return; + this._selectedBlockIndex = blockIndex; + }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._unsubscribeHtml?.(); + this._unsubscribeSelect?.(); + } + + get _selectedPath() { + const { org, site, path } = this._hashCtrl.value ?? {}; + return org && site && path ? `${org}/${site}/${path}` : ''; + } + + willUpdate() { + const sp = this._selectedPath; + if (this._prevSelectedPath !== undefined && sp !== this._prevSelectedPath) { + this._sections = undefined; + this._selectedBlockIndex = undefined; + } + this._prevSelectedPath = sp; + } + + _select(blockIndex) { + this._selectedBlockIndex = blockIndex; + editorSelectChange.emit({ blockIndex, source: 'outline' }); + } + + _clearDropIndicator() { + this.shadowRoot.querySelector('[data-drop-position]')?.removeAttribute('data-drop-position'); + } + + _setDropIndicator(el, data) { + this._clearDropIndicator(); + el.dataset.dropPosition = data.dropPosition; + this._dropTarget = data; + } + + _clearDragState() { + this._clearDropIndicator(); + this._dragSourceEl?.classList.remove('dragging'); + this._dragSourceEl = null; + this._dragging = null; + this._dropTarget = null; + } + + _onDragStart(e, type, index) { + this._dragging = { type, index }; + const el = type === OUTLINE_TYPES.SECTION ? e.currentTarget.parentElement : e.currentTarget; + el.classList.add('dragging'); + this._dragSourceEl = el; + e.dataTransfer.effectAllowed = 'move'; + } + + _onSectionDragOver(e, sec) { + const rect = e.currentTarget.getBoundingClientRect(); + const dropPosition = e.clientY < rect.top + rect.height / 2 + ? DROP_POSITIONS.BEFORE : DROP_POSITIONS.AFTER; + + if (this._dragging?.type === OUTLINE_TYPES.SECTION) { + if (this._dragging.index === sec.sectionIndex) return; + e.preventDefault(); + + const el = dropPosition === DROP_POSITIONS.BEFORE + ? e.currentTarget.querySelector('[data-section-header]') + : e.currentTarget; + + this._setDropIndicator(el, { sectionIndex: sec.sectionIndex, dropPosition }); + } else { + if (!sec.blocks.length) return; + if (sec.blocks.some((b) => b.blockIndex === this._dragging?.index)) return; + const { blockIndex } = sec.blocks[sec.blocks.length - 1]; + e.preventDefault(); + + const lastBlockEl = this.shadowRoot.querySelector(`[data-block-index="${blockIndex}"]`); + if (!lastBlockEl) return; + this._setDropIndicator(lastBlockEl, { blockIndex, dropPosition: DROP_POSITIONS.AFTER }); + } + } + + _onBlockDragOver(e, blockIndex) { + if (this._dragging?.type !== OUTLINE_TYPES.BLOCK || this._dragging.index === blockIndex) return; + e.preventDefault(); + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + const dropPosition = e.clientY < rect.top + rect.height / 2 + ? DROP_POSITIONS.BEFORE : DROP_POSITIONS.AFTER; + this._setDropIndicator(e.currentTarget, { blockIndex, dropPosition }); + } + + _onDrop = (e) => { + e.preventDefault(); + e.stopPropagation(); + const { _dragging, _dropTarget } = this; + this._clearDragState(); + if (!_dropTarget || !_dragging) return; + const { view } = getExtensionsBridge(); + if (_dropTarget.blockIndex != null) { + if (_dragging.type !== OUTLINE_TYPES.BLOCK) return; + moveBlock(view, _dragging.index, _dropTarget.blockIndex, _dropTarget.dropPosition); + } else if (_dropTarget.sectionIndex != null) { + if (_dragging.type !== OUTLINE_TYPES.SECTION) return; + moveSection(view, _dragging.index, _dropTarget.sectionIndex, _dropTarget.dropPosition); + } + }; + + _onDragEnd = () => { + this._clearDragState(); + }; + + _onDragLeave = (e) => { + if (!e.currentTarget.contains(e.relatedTarget)) { + this._clearDropIndicator(); + this._dropTarget = null; + } + }; + + _onTreeKeydown = (e) => treeKeydown(e, this.shadowRoot); + + _renderSection(sec, isFirstSection) { + return html` +
  • this._onSectionDragOver(e, sec)} + @dragleave=${this._onDragLeave} + @drop=${this._onDrop}> +
    this._onDragStart(e, OUTLINE_TYPES.SECTION, sec.sectionIndex)} + @dragend=${this._onDragEnd}> + +
    +
      + ${sec.blocks.length === 0 + ? html`
    • + No blocks +
    • ` + : sec.blocks.map(({ name, blockIndex }, blockIdx) => html` +
    • this._onDragStart(e, OUTLINE_TYPES.BLOCK, blockIndex)} + @dragover=${(e) => this._onBlockDragOver(e, blockIndex)} + @drop=${this._onDrop} + @dragend=${this._onDragEnd} + @click=${() => this._select(blockIndex)}>${name}
    • `)} +
    +
  • `; + } + + render() { + if (!this._selectedPath) { + return html`
    +

    Select a page to see its outline.

    +
    `; + } + + return html` +
    +
    + ${!this._sections + ? html`

    No blocks found.

    ` + : html`
      + ${this._sections.map((sec, i) => this._renderSection(sec, i === 0))} +
    `} +
    +
    `; + } +} + +customElements.define('ew-page-outline', EwPageOutline); diff --git a/blocks/canvas/ew-panel-extensions/aem-assets.js b/blocks/canvas/ew-panel-extensions/aem-assets.js new file mode 100644 index 00000000..d221ded7 --- /dev/null +++ b/blocks/canvas/ew-panel-extensions/aem-assets.js @@ -0,0 +1,173 @@ +/* eslint-disable import/no-unresolved -- importmap */ +import { DOMParser as PMDOMParser } from 'da-y-wrapper'; +import { getNx } from '../../../scripts/utils.js'; +import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js'; + +const { fetchDaConfigs, getFirstSheet } = await import(`${getNx()}/utils/daConfig.js`); + +const ASSET_SELECTOR_URL = 'https://experience.adobe.com/solutions/CQ-assets-selectors/static-assets/resources/assets-selectors.js'; +const DEFAULT_BASE_PATH = '/adobe/assets'; + +// --------------------------------------------------------------------------- +// Config helpers +// --------------------------------------------------------------------------- + +async function getRepositoryConfig(org, site) { + const configs = await Promise.all(fetchDaConfigs({ org, site })); + const entries = configs + .filter((c) => !c?.error) + .reverse() + .flatMap((c) => getFirstSheet(c) || []); + const getValue = (key) => entries.find((e) => e.key === key)?.value || null; + + const repositoryId = getValue('aem.repositoryId'); + if (!repositoryId) return null; + + const tierType = repositoryId.startsWith('delivery') ? 'delivery' : 'author'; + const customOrigin = getValue('aem.assets.prod.origin'); + const isDmEnabled = getValue('aem.asset.dm.delivery') === 'on' + || getValue('aem.asset.smartcrop.select') === 'on' + || tierType === 'delivery'; + + let assetOrigin; + if (customOrigin) assetOrigin = customOrigin; + else if (tierType === 'delivery') assetOrigin = repositoryId; + else if (isDmEnabled) assetOrigin = repositoryId.replace('author', 'delivery'); + else assetOrigin = repositoryId.replace('author', 'publish'); + + const assetBasePath = getValue('aem.assets.prod.basepath') || DEFAULT_BASE_PATH; + + return { repositoryId, tierType, assetOrigin, assetBasePath, isDmEnabled }; +} + +// --------------------------------------------------------------------------- +// URL builders +// --------------------------------------------------------------------------- + +function buildDeliveryUrl(asset, host, basePath) { + const id = asset['repo:assetId'] || asset['repo:id']; + const name = asset['repo:name'] || asset.name || ''; + const seoName = name.includes('.') ? name.split('.').slice(0, -1).join('.') : name; + return `https://${host}${basePath}/${id}/as/${seoName}.avif`; +} + +function buildDmUrl(asset, host, basePath) { + const base = `https://${host}${basePath}/${asset['repo:id']}`; + const mimetype = (asset.mimetype || asset['dc:format'] || '').toLowerCase(); + if (mimetype.startsWith('video/')) return `${base}/play`; + const seoName = asset.name?.includes('.') + ? asset.name.split('.').slice(0, -1).join('.') + : asset.name; + return `${base}/as/${seoName}.avif`; +} + +function buildAuthorUrl(asset, publishOrigin) { + return `https://${publishOrigin}${asset.path}`; +} + +function resolveAssetUrl(asset, config) { + const { tierType, assetOrigin, assetBasePath, isDmEnabled } = config; + if (tierType === 'delivery') return buildDeliveryUrl(asset, assetOrigin, assetBasePath); + if (isDmEnabled) return buildDmUrl(asset, assetOrigin, assetBasePath); + return buildAuthorUrl(asset, assetOrigin); +} + +// --------------------------------------------------------------------------- +// Insertion +// --------------------------------------------------------------------------- + +function insertImage(view, src, alt) { + const attrs = { src, style: 'width: 180px' }; + if (alt) attrs.alt = alt; + const node = view.state.schema.nodes.image.create(attrs); + view.dispatch(view.state.tr.replaceSelectionWith(node).scrollIntoView()); +} + +function insertLink(view, src) { + const para = document.createElement('p'); + const link = document.createElement('a'); + link.href = src; + link.innerText = src; + para.append(link); + const parsed = PMDOMParser.fromSchema(view.state.schema).parse(para); + view.dispatch(view.state.tr.replaceSelectionWith(parsed).scrollIntoView()); +} + +function getAssetAlt(asset) { + return asset['dc:title']?.['o:default'] + || asset['dc:title'] + || asset.name + || ''; +} + +// --------------------------------------------------------------------------- +// Script loader +// --------------------------------------------------------------------------- + +let selectorScriptLoaded; + +function loadSelectorScript() { + selectorScriptLoaded ??= new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = ASSET_SELECTOR_URL; + script.onload = resolve; + script.onerror = reject; + document.head.append(script); + }); + return selectorScriptLoaded; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Renders the AEM asset selector into `container`; selections insert into the editor. */ +export async function renderAssets({ container, org, site, onClose }) { + const { loadIms, handleSignIn } = await import(`${getNx()}/utils/ims.js`); + const ims = await loadIms(); + if (ims?.anonymous) handleSignIn(); + const token = ims?.accessToken?.token; + if (!token) return; + + const repoConfig = await getRepositoryConfig(org, site); + if (!repoConfig) return; + + await loadSelectorScript(); + + const selectorProps = { + imsToken: token, + repositoryId: repoConfig.repositoryId, + aemTierType: repoConfig.tierType, + featureSet: ['upload', 'collections', 'detail-panel', 'advisor'], + ...(onClose && { onClose }), + handleSelection: (assets) => { + const [asset] = assets; + if (!asset) return; + const { view } = getExtensionsBridge(); + if (!view) return; + const src = resolveAssetUrl(asset, repoConfig); + const mimetype = (asset.mimetype || asset['dc:format'] || '').toLowerCase(); + const alt = getAssetAlt(asset); + if (mimetype.startsWith('image/')) { + insertImage(view, src, alt); + } else { + insertLink(view, src); + } + }, + }; + + window.PureJSSelectors.renderAssetSelector(container, selectorProps); +} + +export function getAssetsPlugin({ org, site }) { + return { + name: 'aem-assets', + title: 'AEM Assets', + experience: 'fullsize-dialog', + ootb: false, + sources: [], + format: '', + org, + site, + }; +} diff --git a/blocks/canvas/ew-panel-extensions/ew-panel-extensions.css b/blocks/canvas/ew-panel-extensions/ew-panel-extensions.css new file mode 100644 index 00000000..25871011 --- /dev/null +++ b/blocks/canvas/ew-panel-extensions/ew-panel-extensions.css @@ -0,0 +1,14 @@ +:host { + position: relative; + display: flex; + flex-direction: column; + height: 100%; + overflow-y: auto; +} + +.ext-iframe { + display: block; + width: 100%; + height: 100%; + border: none; +} diff --git a/blocks/canvas/ew-panel-extensions/ew-panel-extensions.js b/blocks/canvas/ew-panel-extensions/ew-panel-extensions.js new file mode 100644 index 00000000..c7b1de9a --- /dev/null +++ b/blocks/canvas/ew-panel-extensions/ew-panel-extensions.js @@ -0,0 +1,57 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { getNx } from '../../../scripts/utils.js'; +import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js'; +import './ew-panel-library.js'; + +const { loadStyle, HashController } = await import(`${getNx()}/utils/utils.js`); + +const style = await loadStyle(import.meta.url); + +class EwPanelExtension extends LitElement { + static properties = { extension: { attribute: false } }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + this._hash = new HashController(this); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._destroyChannel?.(); + } + + async _handlePluginLoad({ target }) { + const hashState = this._hash.value || {}; + const { setupIframeChannel } = await import('./iframe-protocol.js'); + this._destroyChannel?.(); + const { destroy } = await setupIframeChannel({ + iframe: target, + hashState, + getView: () => getExtensionsBridge().view, + onClose: () => this.dispatchEvent( + new CustomEvent('nx-panel-close', { bubbles: true, composed: true }), + ), + }); + this._destroyChannel = destroy; + } + + render() { + const ext = this.extension; + if (!ext) return nothing; + + if (ext.ootb) { + return html``; + } + + return html``; + } +} + +customElements.define('ew-panel-extension', EwPanelExtension); diff --git a/blocks/canvas/ew-panel-extensions/ew-panel-library.css b/blocks/canvas/ew-panel-extensions/ew-panel-library.css new file mode 100644 index 00000000..dbbb1fdc --- /dev/null +++ b/blocks/canvas/ew-panel-extensions/ew-panel-library.css @@ -0,0 +1,296 @@ +:host { + position: relative; + display: flex; + flex-direction: column; + height: 100%; + overflow-y: auto; +} + + +.ext-state { + padding: 16px; + color: light-dark(var(--s2-gray-700), var(--s2-gray-300)); + font-size: 14px; +} + +.ext-list { + list-style: none; + margin: 0; + padding: 0; +} + +.ext-group { + border-bottom: 1px solid light-dark(var(--s2-gray-200), var(--s2-gray-600)); +} + +.ext-group-header { + display: flex; + align-items: center; + width: 100%; +} + +.ext-group-title { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + padding: 10px 0 10px 16px; + background: none; + border: none; + cursor: pointer; + text-align: left; + font-size: 14px; + color: inherit; + gap: 8px; +} + +.ext-group-title:hover { + background: light-dark(var(--s2-gray-100), var(--s2-gray-700)); +} + +.ext-expand-icon { + font-size: 12px; + color: light-dark(var(--s2-gray-600), var(--s2-gray-400)); + flex-shrink: 0; +} + +.ext-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + margin: 0; + border: none; + border-radius: 4px; + background: none; + cursor: pointer; + font-size: 14px; + color: light-dark(var(--s2-gray-600), var(--s2-gray-400)); + flex-shrink: 0; +} + +.ext-action-btn:hover { + background: light-dark(var(--s2-gray-200), var(--s2-gray-600)); + color: light-dark(var(--s2-gray-900), var(--s2-gray-100)); +} + +.ext-preview-btn { + margin-right: 4px; +} + +.ext-add-btn { + margin-right: 8px; +} + +.ext-icon { + width: 20px; + height: 20px; + flex: 0 0 auto; + display: block; + color: inherit; +} + +.ext-action-btn .ext-icon { + pointer-events: none; +} + +.ext-variant-list { + list-style: none; + margin: 0; + padding: 0; + border-top: 1px solid light-dark(var(--s2-gray-200), var(--s2-gray-600)); +} + +.ext-variants-loading { + padding: 8px 24px; + font-size: 13px; + color: light-dark(var(--s2-gray-600), var(--s2-gray-400)); +} + +.ext-variant-item { + padding: 8px 0 8px 24px; + border-bottom: 1px solid light-dark(var(--s2-gray-100), var(--s2-gray-700)); +} + +.ext-variant-item:hover { + background: light-dark(var(--s2-gray-100), var(--s2-gray-700)); +} + +.ext-variant-header { + display: flex; + align-items: center; +} + +.ext-variant-title { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + background: none; + border: none; + cursor: pointer; + text-align: left; + padding: 0; + color: inherit; +} + +.ext-variant-name { + font-size: 13px; + font-weight: 500; +} + +.ext-variant-subtitle { + font-size: 11px; + color: light-dark(var(--s2-gray-500), var(--s2-gray-400)); + margin-top: 2px; +} + +.ext-variant-actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.ext-description { + padding: 6px 16px 6px 0; + font-size: 12px; + line-height: 1.4; + color: light-dark(var(--s2-gray-600), var(--s2-gray-400)); +} + +.ext-item { + display: flex; + align-items: center; + border-bottom: 1px solid light-dark(var(--s2-gray-200), var(--s2-gray-600)); +} + +.ext-item:hover { + background: light-dark(var(--s2-gray-100), var(--s2-gray-700)); +} + +.ext-item-title { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + padding: 8px 0 8px 16px; + background: none; + border: none; + cursor: pointer; + text-align: left; + font-size: 14px; + color: inherit; + gap: 8px; +} + +.ext-item-actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.ext-item-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ext-item-value { + flex-shrink: 0; + margin-left: 8px; + font-size: 12px; + color: light-dark(var(--s2-gray-600), var(--s2-gray-400)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 120px; +} + +.ext-preview-dialog { + position: fixed; + inset: 0; + width: 90vw; + max-width: 1200px; + height: 80vh; + margin: auto; + padding: 0; + border: 1px solid light-dark(var(--s2-gray-300), var(--s2-gray-600)); + border-radius: 8px; + background: light-dark(var(--s2-gray-50), var(--s2-gray-800)); + color: inherit; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.ext-preview-dialog::backdrop { + background: rgb(0 0 0 / 40%); +} + +.ext-preview-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid light-dark(var(--s2-gray-200), var(--s2-gray-600)); +} + +.ext-preview-title { + margin: 0; + font-size: 14px; + font-weight: 600; +} + +.ext-preview-close { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 4px; + background: none; + cursor: pointer; + font-size: 16px; + color: inherit; +} + +.ext-preview-close:hover { + background: light-dark(var(--s2-gray-200), var(--s2-gray-600)); +} + +.ext-preview-body { + flex: 1; + position: relative; + overflow: hidden; +} + +.ext-preview-frame { + display: block; + width: 100%; + height: 100%; + border: none; +} + +.ext-preview-frame.hide-iframe { + visibility: hidden; +} + +.ext-preview-error { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: light-dark(var(--s2-gray-50), var(--s2-gray-800)); + font-size: 14px; + color: light-dark(var(--s2-gray-700), var(--s2-gray-300)); +} diff --git a/blocks/canvas/ew-panel-extensions/ew-panel-library.js b/blocks/canvas/ew-panel-extensions/ew-panel-library.js new file mode 100644 index 00000000..a69bc93d --- /dev/null +++ b/blocks/canvas/ew-panel-extensions/ew-panel-library.js @@ -0,0 +1,269 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { getNx } from '../../../scripts/utils.js'; +import { + fetchBlocks, + fetchItems, + insertBlock, + insertText, + insertTemplate, + getPreviewStatus, + getItemPreviewUrl, +} from './helpers.js'; +import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js'; + +const { loadStyle, HashController } = await import(`${getNx()}/utils/utils.js`); +const style = await loadStyle(import.meta.url); + +const iconAdd = () => html``; +const iconPreview = () => html``; + +/** + * First-party library panel: blocks, templates, icons, placeholders (OOTB sheet-driven tools). + */ +class EwPanelLibrary extends LitElement { + static properties = { + extension: { attribute: false }, + _items: { state: true }, + _blockVariants: { state: true }, + _expandedBlock: { state: true }, + _preview: { state: true }, + _tooltipOpen: { state: true }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + this._hash = new HashController(this); + } + + willUpdate(changed) { + if (changed.has('extension') && this.extension) { + this._items = undefined; + this._blockVariants = new Map(); + this._expandedBlock = null; + this._preview = undefined; + this._tooltipOpen = null; + this._loadItems(); + } + } + + updated() { + const dialog = this.shadowRoot.querySelector('dialog'); + if (dialog && !dialog.open) dialog.showModal(); + } + + async _loadItems() { + const ext = this.extension; + if (!ext) return; + + if (!ext.ootb) return; + + if (ext.name === 'blocks') { + this._items = await fetchBlocks(ext.sources); + return; + } + + let defaultFormat = ''; + if (ext.name === 'icons') defaultFormat = '::'; + else if (ext.name === 'placeholders') defaultFormat = '{{}}'; + this._items = await fetchItems(ext.sources, ext.format || defaultFormat); + } + + async _toggleBlock(block) { + if (this._expandedBlock === block.path) { + this._expandedBlock = null; + return; + } + this._expandedBlock = block.path; + if (!this._blockVariants.has(block.path)) { + const variants = await block.loadVariants; + const next = new Map(this._blockVariants); + next.set(block.path, variants ?? []); + this._blockVariants = next; + } + } + + _insertBlock(variant) { + const { view } = getExtensionsBridge(); + if (!view) return; + insertBlock(view, variant.dom); + } + + _insertText(item) { + const { view } = getExtensionsBridge(); + if (!view) return; + insertText(view, item.text); + } + + async _insertTemplate(item) { + const { view } = getExtensionsBridge(); + if (!view) return; + await insertTemplate(view, item.path); + } + + async _openPreview(item) { + const { org, site } = this._hash.value || {}; + if (!org || !site) return; + const details = getItemPreviewUrl(item, { org, site }); + this._preview = { + name: item.name || item.key || item.title, + url: details.previewUrl, + }; + this._preview.ok = await getPreviewStatus({ + org: details.org, + site: details.site, + pathname: details.pathname, + }); + this.requestUpdate(); + } + + async _closePreview() { + this._preview = undefined; + await this.updateComplete; + this.shadowRoot.querySelector('button')?.focus(); + } + + _toggleTooltip(key) { + this._tooltipOpen = this._tooltipOpen === key ? null : key; + } + + _renderVariants(block) { + if (this._expandedBlock !== block.path) return nothing; + const variants = this._blockVariants.get(block.path); + if (variants === undefined) { + return html`
    Loading variants…
    `; + } + if (!variants.length) { + return html`
    No variants found.
    `; + } + return html` +
      + ${variants.map((v) => html` +
    • +
      + +
      + ${v.description ? html` + ` : nothing} + +
      +
      + ${v.description && this._tooltipOpen === v.name + ? html`
      ${v.description}
      ` : nothing} +
    • + `)} +
    + `; + } + + _renderBlocks() { + if (this._items === undefined) return html`
    Loading…
    `; + if (!this._items.length) return html`
    No blocks found.
    `; + return html` +
      + ${this._items.map((block) => html` +
    • +
      + + +
      + ${this._renderVariants(block)} +
    • + `)} +
    + `; + } + + _renderTemplates() { + if (this._items === undefined) return html`
    Loading…
    `; + if (!this._items.length) return html`
    No templates found.
    `; + return html` +
      + ${this._items.map((item) => html` +
    • + +
      + + +
      +
    • + `)} +
    + `; + } + + _renderKeyValueItems(label) { + if (this._items === undefined) return html`
    Loading…
    `; + if (!this._items.length) return html`
    No ${label} found.
    `; + return html` +
      + ${this._items.map((item) => html` +
    • + +
      + +
      +
    • + `)} +
    + `; + } + + _renderPreviewDialog() { + if (!this._preview) return nothing; + const { ok, name, url } = this._preview; + const hideIframe = ok === undefined || ok === false ? 'hide-iframe' : ''; + const error = ok === false + ? `It appears ${name} has not been previewed.` + : undefined; + + return html` + this._closePreview()}> +
    +

    ${name} preview

    + +
    +
    + ${error ? html`

    ${error}

    ` : nothing} + +
    +
    + `; + } + + render() { + const ext = this.extension; + if (!ext) { + return html`
    No extension.
    `; + } + + const body = (() => { + if (ext.name === 'blocks') return this._renderBlocks(); + if (ext.name === 'templates') return this._renderTemplates(); + return this._renderKeyValueItems(ext.name); + })(); + + return html`${body}${this._renderPreviewDialog()}`; + } +} + +customElements.define('ew-panel-library', EwPanelLibrary); diff --git a/blocks/canvas/ew-panel-extensions/helpers.js b/blocks/canvas/ew-panel-extensions/helpers.js new file mode 100644 index 00000000..c2307da3 --- /dev/null +++ b/blocks/canvas/ew-panel-extensions/helpers.js @@ -0,0 +1,479 @@ +/* eslint-disable import/no-unresolved -- importmap */ +import { DOMParser as PMDOMParser, DOMSerializer, Slice, TextSelection } from 'da-y-wrapper'; +import { getNx } from '../../../scripts/utils.js'; +import { daFetch } from '../../shared/utils.js'; +import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js'; + +const { HLX_ADMIN, hashChange } = await import(`${getNx()}/utils/utils.js`); +const { fetchDaConfigs, getFirstSheet } = await import(`${getNx()}/utils/daConfig.js`); + +const ref = new URLSearchParams(window.location.search).get('ref') || 'main'; + +const AEM_ORIGINS = ['hlx.page', 'hlx.live', 'aem.page', 'aem.live']; +const REPLACE_CONTENT = ''; + +// --------------------------------------------------------------------------- +// Block HTML parsing — ported from da-live helpers/index.js +// --------------------------------------------------------------------------- + +function isHeading(el) { + return ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(el?.nodeName); +} + +function getBlockName(className) { + const [name, ...rest] = (className || '').split(' '); + return { name, variants: rest.length ? rest.join(', ') : undefined }; +} + +function getBlockTableHtml(block) { + const { name, variants } = getBlockName(block.className); + const rows = [...block.children]; + const maxCols = rows.reduce((n, row) => Math.max(n, row.children.length), 0) || 1; + + const table = document.createElement('table'); + table.setAttribute('border', '1'); + + const headerRow = document.createElement('tr'); + const th = document.createElement('td'); + th.setAttribute('colspan', String(maxCols)); + th.textContent = variants ? `${name} (${variants})` : name; + headerRow.append(th); + table.append(headerRow); + + rows.forEach((row) => { + const tr = document.createElement('tr'); + [...row.children].forEach((col) => { + const td = document.createElement('td'); + if (row.children.length < maxCols) td.setAttribute('colspan', String(maxCols)); + td.innerHTML = col.innerHTML; + tr.append(td); + }); + table.append(tr); + }); + + return table; +} + +function decorateImages(element, path) { + try { + const { origin } = new URL(path); + element.querySelectorAll('img').forEach((img) => { + if (img.getAttribute('src')?.startsWith('./')) { + img.src = `${origin}/${img.src.split('/').pop()}`; + } + const ratio = img.width > 200 ? 200 / img.width : 1; + img.width = Math.round(img.width * ratio); + img.height = Math.round(img.height * ratio); + }); + } catch { /* leave images as-is */ } +} + +async function fetchAndParseHtml(path, isAemHosted) { + try { + const resp = await daFetch(`${path}${isAemHosted ? '.plain.html' : ''}`, { noRedirect: true }); + if (!resp.ok) return null; + return new window.DOMParser().parseFromString(await resp.text(), 'text/html'); + } catch { return null; } +} + +function getSectionsAndBlocks(doc) { + return [...doc.querySelectorAll('body > div, main > div')].reduce((acc, section) => { + const hr = document.createElement('hr'); + hr.dataset.issection = 'true'; + acc.push(hr, ...section.querySelectorAll(':scope > *')); + return acc; + }, []); +} + +function processGroupBlock(block) { + const container = document.createElement('div'); + [...block.children].forEach((child) => { + container.append(child.tagName === 'DIV' ? getBlockTableHtml(child) : child.cloneNode(true)); + }); + return container; +} + +function groupBlocks(elements) { + return elements.reduce((state, el) => { + if (el.classList?.contains('library-container-start')) { + const blockGroup = document.createElement('div'); + blockGroup.dataset.isgroup = 'true'; + if (isHeading(el.previousElementSibling)) { + blockGroup.dataset.groupheading = el.previousElementSibling.textContent; + } + state.currentGroup = { blockGroup }; + } else if (el.classList?.contains('library-container-end') && state.currentGroup) { + const { blockGroup } = state.currentGroup; + if (el.nextElementSibling?.classList.contains('library-metadata')) { + blockGroup.append(el.nextElementSibling.cloneNode(true)); + } + state.blocks.push(blockGroup); + state.currentGroup = null; + } else if (state.currentGroup) { + state.currentGroup.blockGroup.append(el.cloneNode(true)); + } else if ( + el.nodeName === 'DIV' + && !el.dataset?.issection + && !el.classList?.contains('library-metadata') + ) { + state.blocks.push(el); + } + return state; + }, { blocks: [], currentGroup: null }).blocks; +} + +function getLibraryMetadata(el) { + return [...el.childNodes].reduce((acc, row) => { + if (row.children) { + const key = row.children[0]?.textContent.trim().toLowerCase(); + const val = row.children[1]?.textContent.trim(); + if (key && val) acc[key] = val; + } + return acc; + }, {}); +} + +function transformBlock(block) { + const prevSib = block.previousElementSibling; + const item = isHeading(prevSib) && prevSib.textContent + ? { name: prevSib.textContent } + : getBlockName(block.className || ''); + item.dom = block.dataset?.isgroup ? processGroupBlock(block) : getBlockTableHtml(block); + + const metaEl = block.nextElementSibling?.classList.contains('library-metadata') + ? block.nextElementSibling + : block.querySelector('.library-metadata'); + if (metaEl) { + const md = getLibraryMetadata(metaEl); + if (md.searchtags) item.tags = md.searchtags; + if (md.description) item.description = md.description; + } + return item; +} + +export async function getBlockVariants(path) { + let isAemHosted = false; + try { + isAemHosted = AEM_ORIGINS.some((o) => new URL(path).origin.endsWith(o)); + } catch { /* relative path */ } + + const doc = await fetchAndParseHtml(path, isAemHosted); + if (!doc) return []; + + decorateImages(doc.body, path); + return groupBlocks(getSectionsAndBlocks(doc)).map(transformBlock); +} + +// --------------------------------------------------------------------------- +// Extension config +// --------------------------------------------------------------------------- + +const OOTB_PLUGINS = new Set(['blocks', 'templates', 'icons', 'placeholders']); + +/** First-party library tools + AEM Assets (not flagged `ootb` in plugin metadata). */ +const LIBRARY_PLUGIN_NAMES = new Set([...OOTB_PLUGINS, 'aem-assets']); + +const LIBRARY_PANEL_ORDER = ['blocks', 'icons', 'templates', 'placeholders', 'aem-assets']; + +function isLibraryExtension(ext) { + return LIBRARY_PLUGIN_NAMES.has(ext.name); +} + +function sortLibraryExtensions(list) { + const orderOf = (name) => { + const i = LIBRARY_PANEL_ORDER.indexOf(name); + return i === -1 ? LIBRARY_PANEL_ORDER.length + 1 : i; + }; + return [...list].sort((a, b) => orderOf(a.name) - orderOf(b.name)); +} + +function getIsPluginAllowed(plugRef) { + const pluginRef = plugRef || 'main'; + if (pluginRef === 'main') return true; + if (ref === 'local') return true; + return pluginRef === ref; +} + +function calculateSources(org, site, sheetPath) { + return sheetPath.split(',').map((p) => { + const trimmed = p.trim(); + if (!trimmed.startsWith('/')) return trimmed; + if (ref === 'local') return `http://localhost:3000${trimmed}`; + return `https://${ref}--${site}--${org}.aem.live${trimmed}`; + }); +} + +function mergePlugin(list, plugin) { + let idx = list.findIndex((p) => p.name === 'templates'); + if (idx === -1) idx = list.findIndex((p) => p.name === 'blocks'); + if (idx !== -1) { + list.splice(idx + 1, 0, plugin); + } else { + list.push(plugin); + } +} + +export async function fetchExtensions(org, site) { + const configs = fetchDaConfigs({ org, site }); + const siteConfig = await configs[configs.length - 1]; + if (siteConfig?.error) return []; + + const rows = siteConfig?.library?.data; + if (!Array.isArray(rows)) return []; + + const extensions = rows.reduce((acc, row) => { + if (!row.title || !getIsPluginAllowed(row.ref)) return acc; + const name = row.title.trim().toLowerCase().replaceAll(' ', '-'); + acc.push({ + name, + title: row.title.trim(), + sources: calculateSources(org, site, row.path), + experience: row.experience || 'inline', + format: row.format || '', + ootb: OOTB_PLUGINS.has(name), + }); + return acc; + }, []); + + try { + const siteEntries = getFirstSheet(siteConfig) || []; + const hasRepo = siteEntries.find((e) => e.key === 'aem.repositoryId')?.value; + if (hasRepo) { + const { getAssetsPlugin } = await import('./aem-assets.js'); + const plugin = getAssetsPlugin({ org, site }); + if (plugin) mergePlugin(extensions, plugin); + } + } catch { /* proceed without assets */ } + + return extensions; +} + +// --------------------------------------------------------------------------- +// Data fetching +// --------------------------------------------------------------------------- + +export async function fetchBlocks(sources) { + const blocks = []; + for (const url of sources) { + try { + const resp = await daFetch(url, { noRedirect: true }); + if (resp.ok) { + const json = await resp.json(); + const data = getFirstSheet(json) ?? (Array.isArray(json) ? json : []); + data.forEach((row) => { + if (row.name && row.path) { + blocks.push({ ...row, loadVariants: getBlockVariants(row.path) }); + } + }); + } + } catch { /* skip failed source */ } + } + return blocks; +} + +export async function fetchItems(sources, format) { + const items = []; + for (const source of sources) { + try { + const resp = await daFetch(source, { noRedirect: true }); + if (resp.ok) { + const json = await resp.json(); + const data = getFirstSheet(json) ?? (Array.isArray(json) ? json : []); + data.forEach((row) => { + const key = row.key ?? row.name; + if (!key && !row.value) return; + const text = format ? format.replace(REPLACE_CONTENT, key ?? '') : (key ?? ''); + items.push({ ...row, key: key ?? '', text }); + }); + } + } catch { /* skip failed source */ } + } + return items; +} + +// --------------------------------------------------------------------------- +// Content insertion +// --------------------------------------------------------------------------- + +export function insertBlock(view, dom) { + const parsed = PMDOMParser.fromSchema(view.state.schema).parse(dom); + const { tr, schema } = view.state; + const insertPos = tr.selection.from; + let newTr = tr.insert(insertPos, schema.nodes.paragraph.create()); + newTr = newTr.replaceSelectionWith(parsed); + const finalPos = Math.min(insertPos + parsed.nodeSize, newTr.doc.content.size); + view.dispatch(newTr.setSelection(TextSelection.create(newTr.doc, finalPos)).scrollIntoView()); +} + +export function insertText(view, text) { + const node = view.state.schema.text(text); + view.dispatch(view.state.tr.replaceSelectionWith(node).scrollIntoView()); +} + +export function insertHTML(view, htmlStr) { + const doc = new window.DOMParser().parseFromString(htmlStr, 'text/html'); + const parsed = PMDOMParser.fromSchema(view.state.schema).parse(doc.body); + const slice = new Slice(parsed.content, 0, 0); + const { from, to } = view.state.selection; + view.dispatch(view.state.tr.replaceRange(from, to, slice).scrollIntoView()); +} + +export function getEditorSelection(view) { + const { selection } = view.state; + if (selection.empty) return null; + const slice = selection.content(); + const serializer = DOMSerializer.fromSchema(view.state.schema); + const fragment = serializer.serializeFragment(slice.content); + const div = document.createElement('div'); + div.appendChild(fragment); + return div.innerHTML; +} + +export async function insertTemplate(view, url) { + const resp = await daFetch(url); + if (!resp.ok) return; + const html = (await resp.text()).replace('class="template-metadata"', 'class="metadata"'); + const doc = new window.DOMParser().parseFromString(html, 'text/html'); + const parsed = PMDOMParser.fromSchema(view.state.schema).parse(doc.body); + view.dispatch(view.state.tr.replaceSelectionWith(parsed).scrollIntoView()); +} + +// --------------------------------------------------------------------------- +// Preview status +// --------------------------------------------------------------------------- + +export async function getPreviewStatus({ org, site, pathname }) { + try { + const resp = await daFetch(`${HLX_ADMIN}/status/${org}/${site}${pathname}`); + if (!resp.ok) return null; + const json = await resp.json(); + return json.preview?.status === 200; + } catch { + return null; + } +} + +export function getItemPreviewUrl(item, { org, site }) { + const url = new URL(item.path || item.value); + const { hostname, pathname } = url; + + let itemOrg = org; + let itemSite = site; + let itemPath = pathname; + + if (hostname.includes('.aem.')) { + const parts = hostname.split('.')[0].split('--').reverse(); + [itemOrg, itemSite] = parts; + } else if (hostname.includes('content.da.live')) { + const segments = pathname.slice(1).split('/'); + [itemOrg, itemSite] = segments; + itemPath = `/${segments.slice(2).join('/')}`; + } + + return { + previewUrl: `https://${ref}--${itemSite}--${itemOrg}.aem.page${itemPath}`, + org: itemOrg, + site: itemSite, + pathname: itemPath, + }; +} + +// --------------------------------------------------------------------------- +// View facade — canvas.js calls this, nothing else +// --------------------------------------------------------------------------- + +function createOutlineView() { + return { + id: 'outline', + label: 'Outline', + section: 'Editor', + firstParty: true, + load: async () => { + await import('../ew-page-outline/ew-page-outline.js'); + return document.createElement('ew-page-outline'); + }, + }; +} + +function createFileExplorerView() { + return { + id: 'files', + label: 'Files', + section: 'Editor', + firstParty: true, + load: async () => { + await import('../ew-file-explorer/ew-file-explorer.js'); + return document.createElement('ew-file-explorer'); + }, + }; +} + +function extensionToPanelView(ext, section) { + const view = { + id: ext.name, + label: ext.title, + section, + firstParty: ext.ootb, + experience: ext.experience, + sources: ext.sources, + load: async () => { + await import('./ew-panel-extensions.js'); + const el = document.createElement('ew-panel-extension'); + el.extension = ext; + return el; + }, + }; + + if (ext.experience === 'fullsize-dialog') { + view.loadModal = async (container, onClose) => { + if (ext.name === 'aem-assets') { + const { renderAssets } = await import('./aem-assets.js'); + await renderAssets({ container, org: ext.org, site: ext.site, onClose }); + return () => {}; + } + + const iframe = document.createElement('iframe'); + iframe.className = 'ext-iframe'; + iframe.src = ext.sources?.[0] ?? ''; + iframe.title = ext.title; + iframe.allow = 'clipboard-write *'; + container.append(iframe); + + let destroyChannel = () => {}; + iframe.addEventListener('load', async () => { + let hashState; + const unsub = hashChange.subscribe((s) => { hashState = s; }); + unsub(); + const { setupIframeChannel } = await import('./iframe-protocol.js'); + const { destroy } = await setupIframeChannel({ + iframe, + hashState: hashState ?? {}, + getView: () => getExtensionsBridge().view, + onClose, + }); + destroyChannel = destroy; + }, { once: true }); + + return () => destroyChannel(); + }; + } + + return view; +} + +/** + * Tool panel: Editor placeholder, Library (blocks / AEM Assets / icons / templates / placeholders), + * Extensions (other plugins). + */ +export async function getCanvasToolPanelViews({ org, site }) { + const extensions = await fetchExtensions(org, site); + const library = sortLibraryExtensions(extensions.filter(isLibraryExtension)); + const thirdParty = extensions.filter((ext) => !isLibraryExtension(ext)); + + return [ + createOutlineView(), + createFileExplorerView(), + ...library.map((ext) => extensionToPanelView(ext, 'Library')), + ...thirdParty.map((ext) => extensionToPanelView(ext, 'Extensions')), + ]; +} diff --git a/blocks/canvas/ew-panel-extensions/iframe-protocol.js b/blocks/canvas/ew-panel-extensions/iframe-protocol.js new file mode 100644 index 00000000..c2a35ce7 --- /dev/null +++ b/blocks/canvas/ew-panel-extensions/iframe-protocol.js @@ -0,0 +1,90 @@ +import { insertText, insertHTML, getEditorSelection } from './helpers.js'; +import { getNx } from '../../../scripts/utils.js'; + +/** + * Wire a two-way MessageChannel between the host and a BYO plugin iframe. + * + * @param {object} opts + * @param {HTMLIFrameElement} opts.iframe + * @param {object} opts.hashState + * @param {Function} opts.getView + * @param {Function} opts.onClose + * @returns {{ channel: MessageChannel, destroy: () => void }} + */ +export async function setupIframeChannel({ iframe, hashState, getView, onClose }) { + const { org, site, path, view } = hashState; + if (!org || !site || !iframe.contentWindow) return { channel: null, destroy() {} }; + + const channel = new MessageChannel(); + + channel.port1.onmessage = (e) => { + const { action, details } = e.data || {}; + const editorView = getView(); + + if (action === 'sendText' && editorView) { + insertText(editorView, details); + } + + if (action === 'sendHTML' && editorView) { + insertHTML(editorView, details); + } + + if (action === 'setHash') { + window.location.hash = details; + } + + if (action === 'setHref') { + window.location.href = details; + } + + if (action === 'closeLibrary') { + onClose(); + } + + if (action === 'getSelection') { + if (!editorView) { + channel.port1.postMessage({ action: 'error', details: 'No editor view' }); + return; + } + const html = getEditorSelection(editorView); + if (!html) { + channel.port1.postMessage({ action: 'error', details: 'No selection found' }); + return; + } + iframe.contentWindow.postMessage( + { action: 'sendSelection', details: html }, + '*', + ); + } + }; + + const project = { + org, + repo: site, + ref: 'main', + path: path ? `/${path}` : '/', + view: view || 'edit', + }; + + let token; + try { + const { loadIms } = await import(`${getNx()}/utils/ims.js`); + const ims = await loadIms(); + token = ims?.accessToken?.token; + } catch { /* proceed without token */ } + + setTimeout(() => { + if (!iframe.contentWindow) return; + iframe.contentWindow.postMessage( + { ready: true, project, context: project, token }, + '*', + [channel.port2], + ); + }, 750); + + const destroy = () => { + channel.port1.close(); + }; + + return { channel, destroy }; +} diff --git a/blocks/canvas/ew-panel-header/ew-panel-header.css b/blocks/canvas/ew-panel-header/ew-panel-header.css new file mode 100644 index 00000000..fd1640ce --- /dev/null +++ b/blocks/canvas/ew-panel-header/ew-panel-header.css @@ -0,0 +1,53 @@ +.panel-header { + display: flex; + align-items: center; + flex-shrink: 0; + min-height: 48px; + padding: 0 8px; +} + +.panel-header-custom { + flex: 1; + display: flex; + align-items: center; + gap: 4px; + justify-content: flex-end; + padding-right: var(--s2-spacing-75); +} + +.panel-header-action[hidden] { + display: none; +} + +.panel-header-action, +.panel-header-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + min-height: 24px; + padding: 0 4px; + margin: 0; + border: none; + border-radius: var(--s2-corner-radius-400); + color: var(--s2-gray-800); + background: transparent; + cursor: pointer; + gap: var(--s2-spacing-75); + font-size: var(--s2-body-size-s); + + img { + display: block; + width: 16px; + height: 16px; + } + + &:focus-visible { + outline: 2px solid var(--s2-blue-800); + outline-offset: 2px; + } + + &:hover { + background-color: var(--s2-gray-75); + } +} diff --git a/blocks/canvas/ew-panel-header/ew-panel-header.js b/blocks/canvas/ew-panel-header/ew-panel-header.js new file mode 100644 index 00000000..cd994aa0 --- /dev/null +++ b/blocks/canvas/ew-panel-header/ew-panel-header.js @@ -0,0 +1,33 @@ +import { getNx } from '../../../scripts/utils.js'; + +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); + +const style = await loadStyle(import.meta.url); + +export default function createPanelHeader({ position, onClose }) { + if (!document.adoptedStyleSheets.includes(style)) { + document.adoptedStyleSheets = [...document.adoptedStyleSheets, style]; + } + const side = position === 'before' ? 'left' : 'right'; + + const bar = document.createElement('div'); + bar.className = 'panel-header'; + + const start = document.createElement('div'); + start.className = 'panel-header-custom'; + + const toggleBtn = document.createElement('button'); + toggleBtn.type = 'button'; + toggleBtn.className = 'panel-header-toggle'; + toggleBtn.setAttribute('aria-label', `Toggle ${position} panel`); + + const img = document.createElement('img'); + img.src = `/blocks/canvas/img/s2-icon-split${side}-20-n.svg`; + img.setAttribute('aria-hidden', 'true'); + toggleBtn.append(img); + + toggleBtn.addEventListener('click', onClose); + + bar.append(start, toggleBtn); + return bar; +} diff --git a/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.css b/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.css new file mode 100644 index 00000000..91efcaab --- /dev/null +++ b/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.css @@ -0,0 +1,169 @@ +:host { + display: contents; +} + +.toolbar-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + max-width: min(92vw, 500px); +} + +.toolbar-actions[data-disabled] { + pointer-events: none; + opacity: 0.45; +} + +.toolbar-btn { + box-sizing: border-box; + min-width: 24px; + height: 24px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: var(--s2-corner-radius-100); + background: none; + color: var(--s2-gray-800); + font: inherit; + cursor: pointer; +} + +.toolbar-btn:hover { + background: var(--s2-gray-200); +} + +.toolbar-btn[aria-pressed="true"] { + background: var(--s2-blue-200); + color: var(--s2-blue-900); +} + +.toolbar-btn[aria-pressed="true"]:hover { + background: var(--s2-blue-300); +} + +.toolbar-btn[data-mark="strong"] { + font-weight: 700; +} + +.toolbar-btn[data-mark="em"] { + font-style: italic; +} + +.toolbar-btn[data-mark="code"] { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.7rem; + letter-spacing: -0.02em; + padding: 0; +} + +.toolbar-btn[hidden] { + display: none; +} + +.toolbar-btn img { + width: 16px; + height: 16px; + display: block; +} + +.toolbar-sep { + width: 1px; + height: 16px; + background: var(--s2-gray-300); + margin: 0 2px; +} + +.toolbar-block-type-wrap { + display: inline-flex; + align-items: center; +} + +/* Link dialog */ + +.link-dialog { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + background: rgb(0 0 0 / 40%); +} + +.link-form { + display: flex; + flex-direction: column; + gap: var(--s2-spacing-100); + padding: var(--s2-spacing-200); + min-width: 340px; + max-width: min(90vw, 420px); + background: var(--s2-gray-25); + border: 1px solid var(--s2-gray-200); + border-radius: var(--s2-corner-radius-500); + box-shadow: 0 8px 32px rgb(0 0 0 / 18%); +} + +.link-form-field { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 0.75rem; + color: var(--s2-gray-700); +} + +.link-form-field input { + box-sizing: border-box; + height: 32px; + padding: 0 var(--s2-spacing-100); + border: 1px solid var(--s2-gray-300); + border-radius: var(--s2-corner-radius-100); + background: var(--s2-gray-50); + color: var(--s2-gray-900); + font: inherit; + font-size: 0.875rem; +} + +.link-form-field input:focus { + outline: 2px solid var(--s2-blue-600); + outline-offset: -1px; + border-color: transparent; +} + +.link-form-actions { + display: flex; + gap: var(--s2-spacing-75); + justify-content: flex-end; + margin-top: var(--s2-spacing-50); +} + +.link-form-actions button { + height: 32px; + padding: 0 16px; + border: none; + border-radius: var(--s2-corner-radius-800); + font: inherit; + font-size: var(--s2-body-size-s, 0.875rem); + font-weight: 600; + cursor: pointer; +} + +.link-form-cancel { + background: var(--s2-gray-200); + color: var(--s2-gray-800); +} + +.link-form-cancel:hover { + background: var(--s2-gray-300); +} + +.link-form-save { + background: var(--s2-blue-900); + color: light-dark(var(--s2-gray-25), #fff); +} + +.link-form-save:hover { + background: var(--s2-blue-1000); +} diff --git a/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.js b/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.js new file mode 100644 index 00000000..9247cf1a --- /dev/null +++ b/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.js @@ -0,0 +1,301 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { getNx } from '../../../scripts/utils.js'; +import { commandsFor, COMMAND_BY_ID } from '../editor-utils/command-defs.js'; +import { + getBlockTypePickerValue, + selectionHasLink, + getLinkInfoInSelection, + applyLink, + removeLink, +} from '../editor-utils/command-helpers.js'; + +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); + +await import(`${getNx()}/blocks/shared/popover/popover.js`); +await import(`${getNx()}/blocks/shared/picker/picker.js`); + +const styles = await loadStyle(import.meta.url); + +const MARK_ITEMS = commandsFor('toolbar-marks'); +const STRUCTURE_ITEMS = commandsFor('toolbar-structure'); +const PICKER_DEFS = commandsFor('toolbar-picker'); + +const BLOCK_TYPE_LABELS = new Map(PICKER_DEFS.map(({ id, label }) => [id, label])); + +const BLOCK_TYPE_PICKER_ITEMS = [ + { section: 'Change into' }, + ...PICKER_DEFS.map(({ id, label }) => ({ value: id, label })), +]; + +const LINK_ICON = new URL('../img/s2-icon-link-20-n.svg', import.meta.url).href; +const UNLINK_ICON = new URL('../img/s2-icon-unlink-20-n.svg', import.meta.url).href; + +function blockTypeLabelForRaw(raw) { + if (raw === 'mixed') return 'Mixed'; + return BLOCK_TYPE_LABELS.get(raw) + ?? raw.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} + +class EwSelectionToolbar extends LitElement { + static properties = { + view: { attribute: false }, + _linkDialogOpen: { state: true }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [styles]; + } + + get _popover() { return this.shadowRoot?.querySelector('nx-popover'); } + + get _picker() { return this.shadowRoot?.querySelector('nx-picker'); } + + show({ x, y }) { + this._popover?.show({ x, y, placement: 'above' }); + this.requestUpdate(); + } + + hide() { + this._popover?.close(); + } + + get open() { + return this._popover?.open ?? false; + } + + _icon(url) { + return html``; + } + + /* ---- Block-type picker ---- */ + + _syncBlockTypePicker() { + const picker = this._picker; + if (!picker || !this.view) return; + const raw = getBlockTypePickerValue(this.view.state); + if (BLOCK_TYPE_LABELS.has(raw)) { + picker.value = raw; + picker.labelOverride = ''; + } else { + picker.value = ''; + picker.labelOverride = blockTypeLabelForRaw(raw); + } + } + + _onBlockTypeChange(e) { + if (!this.view) return; + const cmd = COMMAND_BY_ID.get(e.detail.value); + if (cmd) { + cmd.apply(this.view); + this.requestUpdate(); + this.view.focus(); + } + } + + /* ---- Mark / structure buttons ---- */ + + _onToolbarClick(e) { + e.preventDefault(); + if (!this.view) return; + const btn = e.target instanceof Element ? e.target.closest('button') : null; + if (!btn || btn.disabled) return; + + const { id, link } = btn.dataset; + if (link === 'create' || link === 'edit') { + this._showLinkDialog(); + return; + } + if (link === 'remove') { + removeLink(this.view); + this.requestUpdate(); + this.view.focus(); + return; + } + if (id) { + COMMAND_BY_ID.get(id)?.apply(this.view); + this.requestUpdate(); + this.view.focus(); + } + } + + _isCommandActive(id) { + if (!this.view) return false; + return COMMAND_BY_ID.get(id)?.active?.(this.view.state) ?? false; + } + + _isCommandVisible(id) { + if (!this.view) return true; + const cmd = COMMAND_BY_ID.get(id); + return cmd?.visible ? cmd.visible(this.view.state) : true; + } + + _isCommandDisabled(id) { + if (!this.view) return false; + const cmd = COMMAND_BY_ID.get(id); + return cmd?.disabled ? cmd.disabled(this.view.state) : false; + } + + _hasLink() { + if (!this.view) return false; + return selectionHasLink(this.view.state); + } + + /* ---- Link dialog ---- */ + + _showLinkDialog() { + if (!this.view) return; + this.hide(); + this._linkDialogOpen = true; + } + + _closeLinkDialog() { + this._linkDialogOpen = false; + this.view?.focus(); + } + + _onLinkDialogSubmit(e) { + e.preventDefault(); + if (!this.view) return; + const form = e.target; + const href = form.elements['link-href'].value.trim(); + if (!href) return; + const text = form.elements['link-text'].value; + this._closeLinkDialog(); + applyLink(this.view, { href, text }); + this.view.focus(); + } + + _onLinkBackdropMousedown(e) { + if (e.target === e.currentTarget) this._closeLinkDialog(); + } + + _onLinkBackdropKeydown(e) { + if (e.key === 'Escape') { + e.stopPropagation(); + this._closeLinkDialog(); + } + } + + get linkDialogOpen() { return this._linkDialogOpen ?? false; } + + /* ---- Rendering ---- */ + + updated() { + this._syncBlockTypePicker(); + } + + _renderMarkButton({ id, label, icon }) { + const pressed = this._isCommandActive(id); + return html` + + `; + } + + _renderStructureButton({ id, label, icon }) { + const hidden = !this._isCommandVisible(id); + const disabled = this._isCommandDisabled(id); + return html` + + `; + } + + _renderLinkButtons() { + const hasLink = this._hasLink(); + return html` + + + + `; + } + + _renderLinkDialog() { + if (!this._linkDialogOpen) return nothing; + const info = this.view ? getLinkInfoInSelection(this.view.state) : null; + + let hrefVal = ''; + let textVal = ''; + if (info) { + hrefVal = info.href; + textVal = info.text; + } else if (this.view) { + const { from, to } = this.view.state.selection; + textVal = from !== to ? this.view.state.doc.textBetween(from, to) : ''; + } + + return html` + + `; + } + + render() { + const disabled = !this.view; + return html` + +
    { e.preventDefault(); e.stopPropagation(); }} + @click=${(e) => this._onToolbarClick(e)}> + + this._onBlockTypeChange(e)} + > + + + ${MARK_ITEMS.map((m) => this._renderMarkButton(m))} + + ${STRUCTURE_ITEMS.map((s) => this._renderStructureButton(s))} + + ${this._renderLinkButtons()} +
    +
    + ${this._renderLinkDialog()} + `; + } +} + +customElements.define('ew-selection-toolbar', EwSelectionToolbar); + +export default EwSelectionToolbar; diff --git a/blocks/canvas/ew-tool-panel/tool-panel.css b/blocks/canvas/ew-tool-panel/tool-panel.css new file mode 100644 index 00000000..c0068850 --- /dev/null +++ b/blocks/canvas/ew-tool-panel/tool-panel.css @@ -0,0 +1,133 @@ +:host { + display: flex; + flex-direction: column; + height: 100%; + font-family: var(--s2-font-family); + overflow: hidden; +} + +.tool-panel-header { + display: flex; + align-items: center; + flex-shrink: 0; + min-height: 48px; + padding: 0 var(--s2-spacing-100); + border-bottom: 1px solid var(--s2-gray-200); +} + +.tool-panel-header-actions { + flex: 1; + display: flex; + align-items: center; + gap: var(--s2-spacing-75); + justify-content: flex-end; + padding-right: var(--s2-spacing-75); +} + +.tool-panel-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + flex-shrink: 0; + border: none; + border-radius: var(--s2-corner-radius-400); + background: transparent; + color: var(--s2-gray-800); + cursor: pointer; + + img { + display: block; + width: 16px; + height: 16px; + } + + &:hover { + background-color: var(--s2-gray-75); + } +} + +.tool-panel-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; + + > * { + flex: 1; + min-height: 0; + } + + > [hidden] { + display: none; + } +} + +.nx-tool-panel-editor-placeholder { + min-height: 120px; +} + +/* Full-size experience dialog (same shell as ew-panel-extension preview). */ +.tool-panel-fullsize-dialog { + position: fixed; + inset: 0; + width: 90vw; + max-width: 1200px; + height: 80vh; + margin: auto; + padding: 0; + border: 1px solid light-dark(var(--s2-gray-300), var(--s2-gray-600)); + border-radius: 8px; + background: light-dark(var(--s2-gray-50), var(--s2-gray-800)); + color: inherit; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.tool-panel-fullsize-dialog::backdrop { + background: rgb(0 0 0 / 40%); +} + +.tool-panel-fullsize-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid light-dark(var(--s2-gray-200), var(--s2-gray-600)); + flex-shrink: 0; +} + +.tool-panel-fullsize-dialog-title { + margin: 0; + font-size: 14px; + font-weight: 600; +} + +.tool-panel-fullsize-dialog-close { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 4px; + background: none; + cursor: pointer; + font-size: 16px; + color: inherit; +} + +.tool-panel-fullsize-dialog-close:hover { + background: light-dark(var(--s2-gray-200), var(--s2-gray-600)); +} + +.tool-panel-fullsize-dialog-body { + flex: 1; + position: relative; + min-height: 0; + overflow: hidden; +} diff --git a/blocks/canvas/ew-tool-panel/tool-panel.js b/blocks/canvas/ew-tool-panel/tool-panel.js new file mode 100644 index 00000000..5546c6c4 --- /dev/null +++ b/blocks/canvas/ew-tool-panel/tool-panel.js @@ -0,0 +1,208 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { getNx } from '../../../scripts/utils.js'; + +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); + +await import(`${getNx()}/blocks/shared/picker/picker.js`); + +const style = await loadStyle(import.meta.url); + +const CLOSE_ICON_SRC = '/img/icons/s2-icon-splitright-20-n.svg'; +const OPEN_IN_ICON_URL = '/img/icons/s2-icon-openin-20-n.svg'; +const ACTIVE_VIEW_KEY = 'nx-tool-panel-active-view'; + +class EwToolPanel extends LitElement { + static properties = { + views: { attribute: false }, + activeId: { type: String }, + _fullsizeDialogViewId: { state: true }, + }; + + _loaded = {}; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + } + + get _fullsizeDialogView() { + const id = this._fullsizeDialogViewId; + if (!id || !this.views) return null; + return this.views.find((v) => v.id === id) ?? null; + } + + _pickerItemsFromViews() { + if (!this.views?.length) return []; + const items = []; + let lastSection; + for (const v of this.views) { + if (v.section && v.section !== lastSection) { + items.push({ section: v.section }); + lastSection = v.section; + } + const opensExternally = v.experience === 'window' || v.experience === 'fullsize-dialog'; + items.push({ + value: v.id, + label: v.label, + ...(opensExternally && { + action: true, + trailingIcon: OPEN_IN_ICON_URL, + ariaLabel: v.experience === 'window' + ? `${v.label} (opens in new tab)` + : `${v.label} (opens in dialog)`, + }), + }); + } + return items; + } + + _pruneLoadedViews() { + const ids = new Set(this.views.map((v) => v.id)); + Object.keys(this._loaded).forEach((id) => { + if (!ids.has(id)) { + this._loaded[id].remove(); + delete this._loaded[id]; + } + }); + } + + async updated(changed) { + if (changed.has('views')) await this._onViewsChange(); + if (changed.has('activeId')) { + if (this.activeId) { + try { sessionStorage.setItem(ACTIVE_VIEW_KEY, this.activeId); } catch { /* ignore */ } + } + this._syncContent(); + this._syncHeaderActions(); + } + if (changed.has('_fullsizeDialogViewId') && this._fullsizeDialogViewId) { + await this._mountDialog(); + } + } + + async _onViewsChange() { + if (!this.views?.length) { + this._closeDialog(); + this.activeId = undefined; + this._loaded = {}; + this.shadowRoot.querySelector('.tool-panel-content').replaceChildren(); + return; + } + + this._pruneLoadedViews(); + const ids = new Set(this.views.map((v) => v.id)); + + if (this._fullsizeDialogViewId && !ids.has(this._fullsizeDialogViewId)) { + this._closeDialog(); + } + + if (!this.activeId || !ids.has(this.activeId)) { + const stored = sessionStorage.getItem(ACTIVE_VIEW_KEY); + await this.showView(stored && ids.has(stored) ? stored : this.views[0].id); + } + } + + async _mountDialog() { + await this.updateComplete; + const dialog = this.shadowRoot.querySelector('.tool-panel-fullsize-dialog'); + const body = dialog.querySelector('.tool-panel-fullsize-dialog-body'); + const viewId = this._fullsizeDialogViewId; + if (body.dataset.mountedFor === viewId) return; + body.innerHTML = ''; + body.dataset.mountedFor = viewId; + if (!dialog.open) dialog.showModal(); + const view = this.views.find((v) => v.id === viewId); + await view.loadModal(body, () => dialog.close()); + } + + _closeDialog() { + const dialog = this.shadowRoot.querySelector('.tool-panel-fullsize-dialog'); + if (dialog?.open) { + dialog.close(); + } else { + this._fullsizeDialogViewId = undefined; + } + } + + async showView(id) { + const consumer = this.views.find((c) => c.id === id); + if (!consumer) return; + if (consumer.experience === 'window') { + window.open( + new URL(consumer.sources[0], window.location.href).href, + '_blank', + 'noopener,noreferrer', + ); + return; + } + if (consumer.experience === 'fullsize-dialog') { + this._fullsizeDialogViewId = id; + return; + } + if (!this._loaded[id]) { + this._loaded[id] = await consumer.load(); + } + this.activeId = id; + } + + _syncContent() { + const content = this.shadowRoot.querySelector('.tool-panel-content'); + Object.entries(this._loaded).forEach(([id, el]) => { + el.hidden = id !== this.activeId; + if (id === this.activeId && !content.contains(el)) content.append(el); + }); + } + + _syncHeaderActions() { + const zone = this.shadowRoot.querySelector('.tool-panel-header-actions'); + zone.textContent = ''; + const consumer = this.views.find((c) => c.id === this.activeId); + if (!consumer?.firstParty) return; + const actions = this._loaded[this.activeId]?.getHeaderActions?.(); + if (actions) zone.append(actions); + } + + _close() { + this.dispatchEvent(new CustomEvent('nx-panel-close', { bubbles: true, composed: true })); + } + + _onFullsizeDialogClose() { + this._fullsizeDialogViewId = undefined; + } + + render() { + const items = this._pickerItemsFromViews(); + const dialogTitle = this._fullsizeDialogView?.label ?? 'Extension'; + + return html` +
    + + this.showView(e.detail.value)} + > +
    +
    +
    + ${this._fullsizeDialogViewId ? html` + +
    +

    ${dialogTitle}

    + +
    +
    +
    + ` : nothing} + `; + } +} + +customElements.define('ew-tool-panel', EwToolPanel); diff --git a/blocks/canvas/img/s2-icon-blockcode-20-n.svg b/blocks/canvas/img/s2-icon-blockcode-20-n.svg new file mode 100644 index 00000000..f1b75494 --- /dev/null +++ b/blocks/canvas/img/s2-icon-blockcode-20-n.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/blocks/canvas/img/s2-icon-blockquote-20-n.svg b/blocks/canvas/img/s2-icon-blockquote-20-n.svg new file mode 100644 index 00000000..9d3034cf --- /dev/null +++ b/blocks/canvas/img/s2-icon-blockquote-20-n.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/blocks/canvas/img/s2-icon-cclibrary-20-n.svg b/blocks/canvas/img/s2-icon-cclibrary-20-n.svg new file mode 100644 index 00000000..e0b7ed27 --- /dev/null +++ b/blocks/canvas/img/s2-icon-cclibrary-20-n.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/blocks/canvas/img/s2-icon-code-20-n.svg b/blocks/canvas/img/s2-icon-code-20-n.svg new file mode 100644 index 00000000..625b4d42 --- /dev/null +++ b/blocks/canvas/img/s2-icon-code-20-n.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/blocks/canvas/img/s2-icon-gridcompare-20-n.svg b/blocks/canvas/img/s2-icon-gridcompare-20-n.svg new file mode 100644 index 00000000..ccf76e06 --- /dev/null +++ b/blocks/canvas/img/s2-icon-gridcompare-20-n.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/blocks/canvas/img/s2-icon-heading1-20-n.svg b/blocks/canvas/img/s2-icon-heading1-20-n.svg new file mode 100644 index 00000000..266cff49 --- /dev/null +++ b/blocks/canvas/img/s2-icon-heading1-20-n.svg @@ -0,0 +1,5 @@ + + + 1 + + diff --git a/blocks/canvas/img/s2-icon-heading2-20-n.svg b/blocks/canvas/img/s2-icon-heading2-20-n.svg new file mode 100644 index 00000000..6453ca1d --- /dev/null +++ b/blocks/canvas/img/s2-icon-heading2-20-n.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/blocks/canvas/img/s2-icon-heading3-20-n.svg b/blocks/canvas/img/s2-icon-heading3-20-n.svg new file mode 100644 index 00000000..9a2d4448 --- /dev/null +++ b/blocks/canvas/img/s2-icon-heading3-20-n.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/blocks/canvas/img/s2-icon-heading4-20-n.svg b/blocks/canvas/img/s2-icon-heading4-20-n.svg new file mode 100644 index 00000000..b2777248 --- /dev/null +++ b/blocks/canvas/img/s2-icon-heading4-20-n.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/blocks/canvas/img/s2-icon-heading5-20-n.svg b/blocks/canvas/img/s2-icon-heading5-20-n.svg new file mode 100644 index 00000000..802c6554 --- /dev/null +++ b/blocks/canvas/img/s2-icon-heading5-20-n.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/blocks/canvas/img/s2-icon-heading6-20-n.svg b/blocks/canvas/img/s2-icon-heading6-20-n.svg new file mode 100644 index 00000000..dcc49ff4 --- /dev/null +++ b/blocks/canvas/img/s2-icon-heading6-20-n.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/blocks/canvas/img/s2-icon-link-20-n.svg b/blocks/canvas/img/s2-icon-link-20-n.svg new file mode 100644 index 00000000..2f54b8a4 --- /dev/null +++ b/blocks/canvas/img/s2-icon-link-20-n.svg @@ -0,0 +1,3 @@ + + + diff --git a/blocks/canvas/img/s2-icon-listbulleted-20-n.svg b/blocks/canvas/img/s2-icon-listbulleted-20-n.svg new file mode 100644 index 00000000..8213dbc8 --- /dev/null +++ b/blocks/canvas/img/s2-icon-listbulleted-20-n.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/blocks/canvas/img/s2-icon-listnumbered-20-n.svg b/blocks/canvas/img/s2-icon-listnumbered-20-n.svg new file mode 100644 index 00000000..8cdb8d6c --- /dev/null +++ b/blocks/canvas/img/s2-icon-listnumbered-20-n.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/blocks/canvas/img/s2-icon-rail-20-n.svg b/blocks/canvas/img/s2-icon-rail-20-n.svg new file mode 100644 index 00000000..b70a99e2 --- /dev/null +++ b/blocks/canvas/img/s2-icon-rail-20-n.svg @@ -0,0 +1,14 @@ + + + + + + S Rail 18 N + + + + \ No newline at end of file diff --git a/blocks/canvas/img/s2-icon-separator-20-n.svg b/blocks/canvas/img/s2-icon-separator-20-n.svg new file mode 100644 index 00000000..c357171f --- /dev/null +++ b/blocks/canvas/img/s2-icon-separator-20-n.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/blocks/canvas/img/s2-icon-splitleft-20-n.svg b/blocks/canvas/img/s2-icon-splitleft-20-n.svg new file mode 100644 index 00000000..3c51c414 --- /dev/null +++ b/blocks/canvas/img/s2-icon-splitleft-20-n.svg @@ -0,0 +1,4 @@ + + + + diff --git a/blocks/canvas/img/s2-icon-splitright-20-n.svg b/blocks/canvas/img/s2-icon-splitright-20-n.svg new file mode 100644 index 00000000..68810c74 --- /dev/null +++ b/blocks/canvas/img/s2-icon-splitright-20-n.svg @@ -0,0 +1,4 @@ + + + + diff --git a/blocks/canvas/img/s2-icon-tableadd-20-n.svg b/blocks/canvas/img/s2-icon-tableadd-20-n.svg new file mode 100644 index 00000000..31a90ff8 --- /dev/null +++ b/blocks/canvas/img/s2-icon-tableadd-20-n.svg @@ -0,0 +1,6 @@ + + + + diff --git a/blocks/canvas/img/s2-icon-tagbold-20-n.svg b/blocks/canvas/img/s2-icon-tagbold-20-n.svg new file mode 100644 index 00000000..ded1b999 --- /dev/null +++ b/blocks/canvas/img/s2-icon-tagbold-20-n.svg @@ -0,0 +1,3 @@ + + + diff --git a/blocks/canvas/img/s2-icon-tagitalic-20-n.svg b/blocks/canvas/img/s2-icon-tagitalic-20-n.svg new file mode 100644 index 00000000..436ab3f9 --- /dev/null +++ b/blocks/canvas/img/s2-icon-tagitalic-20-n.svg @@ -0,0 +1,3 @@ + + + diff --git a/blocks/canvas/img/s2-icon-tagstrikethrough-20-n.svg b/blocks/canvas/img/s2-icon-tagstrikethrough-20-n.svg new file mode 100644 index 00000000..e3ba30f9 --- /dev/null +++ b/blocks/canvas/img/s2-icon-tagstrikethrough-20-n.svg @@ -0,0 +1,4 @@ + + + + diff --git a/blocks/canvas/img/s2-icon-tagunderline-20-n.svg b/blocks/canvas/img/s2-icon-tagunderline-20-n.svg new file mode 100644 index 00000000..9022c1ed --- /dev/null +++ b/blocks/canvas/img/s2-icon-tagunderline-20-n.svg @@ -0,0 +1,4 @@ + + + + diff --git a/blocks/canvas/img/s2-icon-textindentdecrease-20-n.svg b/blocks/canvas/img/s2-icon-textindentdecrease-20-n.svg new file mode 100644 index 00000000..d3196ded --- /dev/null +++ b/blocks/canvas/img/s2-icon-textindentdecrease-20-n.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/blocks/canvas/img/s2-icon-textindentincrease-20-n.svg b/blocks/canvas/img/s2-icon-textindentincrease-20-n.svg new file mode 100644 index 00000000..966cf0f6 --- /dev/null +++ b/blocks/canvas/img/s2-icon-textindentincrease-20-n.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/blocks/canvas/img/s2-icon-unlink-20-n.svg b/blocks/canvas/img/s2-icon-unlink-20-n.svg new file mode 100644 index 00000000..b63fea9f --- /dev/null +++ b/blocks/canvas/img/s2-icon-unlink-20-n.svg @@ -0,0 +1,18 @@ + + + + + S Unlink 18 N + + + + + + + + + \ No newline at end of file diff --git a/blocks/canvas/utils/tree-nav.js b/blocks/canvas/utils/tree-nav.js new file mode 100644 index 00000000..585cde9a --- /dev/null +++ b/blocks/canvas/utils/tree-nav.js @@ -0,0 +1,35 @@ +export function treeEnsureTabStop(shadowRoot) { + const items = [...shadowRoot.querySelectorAll('[role="treeitem"]')]; + if (items.length && !items.some((el) => el.tabIndex === 0)) items[0].tabIndex = 0; +} + +export function treeFocusIn(e, shadowRoot) { + const item = e.target.closest('[role="treeitem"]'); + if (!item) return; + shadowRoot.querySelectorAll('[role="treeitem"]').forEach((el) => { + el.tabIndex = el === item ? 0 : -1; + }); +} + +export function treeKeydown(e, shadowRoot) { + const items = [...shadowRoot.querySelectorAll('[role="treeitem"]')]; + if (!items.length) return; + const idx = items.indexOf(shadowRoot.activeElement); + if (idx === -1) return; + + let next = idx; + switch (e.key) { + case 'ArrowDown': next = Math.min(idx + 1, items.length - 1); break; + case 'ArrowUp': next = Math.max(idx - 1, 0); break; + case 'Home': next = 0; break; + case 'End': next = items.length - 1; break; + default: return; + } + + if (next !== idx) { + e.preventDefault(); + items[idx].tabIndex = -1; + items[next].tabIndex = 0; + items[next].focus(); + } +} diff --git a/blocks/edit/da-library/helpers/helpers.js b/blocks/edit/da-library/helpers/helpers.js index 518b0ba0..18033486 100644 --- a/blocks/edit/da-library/helpers/helpers.js +++ b/blocks/edit/da-library/helpers/helpers.js @@ -48,7 +48,7 @@ export async function getItems(sources, format) { const items = []; for (const source of sources) { try { - const resp = await daFetch(source); + const resp = await daFetch(source, { noRedirect: true }); const json = await resp.json(); if (json.data) { items.push(...formatData(json.data, format)); diff --git a/blocks/inventory/action-bar/action-bar.css b/blocks/inventory/action-bar/action-bar.css new file mode 100644 index 00000000..064e88c1 --- /dev/null +++ b/blocks/inventory/action-bar/action-bar.css @@ -0,0 +1,88 @@ +:host { + display: flex; + align-items: center; + min-height: var(--browse-title-bar-height, var(--s2-spacing-500)); + padding: 0 var(--s2-spacing-200); + box-sizing: border-box; + border-radius: var(--s2-corner-radius-500); + background-color: var(--s2-blue-900); + color: light-dark(var(--s2-gray-25), #fff); + font-family: var(--s2-font-family); +} + +.left { + display: flex; + align-items: center; + gap: var(--s2-spacing-100); +} + +.actions { + display: flex; + align-items: center; + gap: var(--s2-spacing-200); + margin-inline-start: auto; +} + +.label { + font-size: var(--s2-component-s-bold-font-size); + white-space: nowrap; +} + +.action-btn { + display: inline-flex; + align-items: center; + gap: var(--s2-spacing-75); + height: 24px; + padding: 0 var(--s2-spacing-150); + border: none; + border-radius: var(--s2-corner-radius-400); + background: inherit; + color: inherit; + font-family: var(--s2-font-family); + font-size: var(--s2-component-s-bold-font-size); + cursor: pointer; + + &:disabled { + opacity: 0.6; + cursor: default; + } + + & svg { + display: block; + width: 14px; + height: 14px; + flex-shrink: 0; + } + + & :is(path, circle, ellipse, line, polyline, polygon) { + fill: currentcolor; + } +} + +.close-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: var(--s2-corner-radius-400); + background: transparent; + color: inherit; + cursor: pointer; + + &:hover { + background-color: rgb(255 255 255 / 15%); + } + + & svg { + display: block; + width: 16px; + height: 16px; + } + + & :is(path, circle, rect, ellipse, line, polyline, polygon) { + fill: currentcolor; + } +} diff --git a/blocks/inventory/action-bar/action-bar.js b/blocks/inventory/action-bar/action-bar.js new file mode 100644 index 00000000..9e7719fa --- /dev/null +++ b/blocks/inventory/action-bar/action-bar.js @@ -0,0 +1,112 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { getNx } from '../../../scripts/utils.js'; +import { isFolder } from '../utils.js'; + +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); +const { loadHrefSvg, ICONS_BASE } = await import(`${getNx()}/utils/svg.js`); + +const ICON_BASE = new URL('../../../img/icons/', import.meta.url).href; +const styles = await loadStyle(import.meta.url); +const [closeIcon, previewIcon, publishIcon, shareIcon, deleteIcon, renameIcon] = await Promise.all([ + loadHrefSvg(`${ICON_BASE}s2-icon-close-20-n.svg`), + loadHrefSvg(`${ICON_BASE}s2-icon-preview-20-n.svg`), + loadHrefSvg(`${ICON_BASE}s2-icon-publish-20-n.svg`), + loadHrefSvg(`${ICON_BASE}s2-icon-share-20-n.svg`), + loadHrefSvg(`${ICON_BASE}s2-icon-delete-20-n.svg`), + loadHrefSvg(`${ICONS_BASE}S2_Icon_Edit_20_N.svg`), +]); + +class NxBrowseActionBar extends LitElement { + static properties = { + selected: { type: Array }, + isDisabled: { type: Boolean, attribute: false }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [styles]; + } + + get _count() { + return this.selected?.length ?? 0; + } + + _onClear() { + this.dispatchEvent(new CustomEvent('nx-action-bar-clear', { + bubbles: true, + composed: true, + })); + } + + _onAction(action) { + this.dispatchEvent(new CustomEvent('nx-browse-selection-action', { + detail: { action }, + bubbles: true, + composed: true, + })); + } + + render() { + const count = this._count; + const singleFile = count === 1 && !isFolder(this.selected[0].item); + + return html` +
    + + + ${count} item${count !== 1 ? 's' : ''} selected + +
    +
    + ${count === 1 ? html` + + ` : nothing} + ${singleFile ? html` + + + ` : nothing} + + +
    + `; + } +} + +if (!customElements.get('nx-browse-action-bar')) { + customElements.define('nx-browse-action-bar', NxBrowseActionBar); +} diff --git a/blocks/inventory/browse-api.js b/blocks/inventory/browse-api.js new file mode 100644 index 00000000..9001215f --- /dev/null +++ b/blocks/inventory/browse-api.js @@ -0,0 +1,85 @@ +import { daFetch } from '../shared/utils.js'; +import { AEM_ORIGIN, DA_ORIGIN, getLivePreviewUrl } from '../shared/constants.js'; + +export async function saveToAem(path, action) { + const normalizedPath = path.startsWith('/') ? path.slice(1) : path; + const orgSlashIndex = normalizedPath.indexOf('/'); + if (orgSlashIndex < 1) { + return { error: 'Invalid path for AEM', status: 0 }; + } + const siteSlashIndex = normalizedPath.indexOf('/', orgSlashIndex + 1); + if (siteSlashIndex < orgSlashIndex + 1) { + return { error: 'Invalid path for AEM', status: 0 }; + } + const owner = normalizedPath.slice(0, orgSlashIndex).toLowerCase(); + const repo = normalizedPath.slice(orgSlashIndex + 1, siteSlashIndex).toLowerCase(); + const aemPath = normalizedPath.slice(siteSlashIndex + 1); + const requestUrl = `${AEM_ORIGIN}/${action}/${owner}/${repo}/main/${aemPath}`; + const response = await daFetch(requestUrl, { method: 'POST' }); + if (!response.ok) { + const headerError = response.headers.get('x-error') || response.statusText || 'AEM request failed'; + return { error: headerError, status: response.status }; + } + try { + const json = await response.json(); + return { json }; + } catch { + return { json: {} }; + } +} + +export async function deploy(sourcePath, action) { + const phases = action === 'publish' ? ['preview', 'live'] : ['preview']; + const openedUrls = []; + for (const phase of phases) { + const result = await saveToAem(sourcePath, phase); + if ('error' in result) return { ok: false }; + if (phase === 'preview' && action === 'preview') { + const url = result.json?.preview?.url; + if (url) openedUrls.push(url); + } else if (phase === 'live') { + const url = result.json?.live?.url; + if (url) openedUrls.push(url); + } + } + return { ok: true, openedUrls }; +} + +export function getItemPreviewUrl(item) { + const path = item?.path || ''; + const normalizedPath = path.startsWith('/') ? path.slice(1) : path; + const orgSlashIndex = normalizedPath.indexOf('/'); + if (orgSlashIndex < 1) return null; + const siteSlashIndex = normalizedPath.indexOf('/', orgSlashIndex + 1); + if (siteSlashIndex < orgSlashIndex + 1) return null; + const owner = normalizedPath.slice(0, orgSlashIndex).toLowerCase(); + const repo = normalizedPath.slice(orgSlashIndex + 1, siteSlashIndex).toLowerCase(); + const aemPath = normalizedPath.slice(siteSlashIndex + 1); + const base = getLivePreviewUrl(owner, repo); + if (!aemPath) return base; + const cleanPath = item.ext === 'html' ? aemPath.replace(/\.html$/, '') : aemPath; + return `${base}/${cleanPath}`; +} + +export async function renameItem(item, newName) { + const { path, name } = item; + const idx = path.lastIndexOf(name); + if (idx < 0) return { ok: false, error: 'Could not determine new path' }; + const newPath = `${path.slice(0, idx)}${newName}${path.slice(idx + name.length)}`; + const body = new FormData(); + body.append('destination', newPath); + const response = await daFetch(`${DA_ORIGIN}/move${path}`, { method: 'POST', body }); + if (response.status === 204 || response.ok) return { ok: true, newPath }; + const error = response.headers?.get('x-error') || response.statusText || 'Rename failed'; + return { ok: false, error }; +} + +export async function deleteSourcePath(path) { + if (!path) return { ok: false, error: 'Missing path' }; + const response = await daFetch(`${DA_ORIGIN}/source${path}`, { method: 'DELETE' }); + if (!response.ok) { + const error = response.headers.get('x-error') || response.statusText || 'Delete failed'; + return { ok: false, status: response.status, error }; + } + return { ok: true }; +} diff --git a/blocks/inventory/delete/delete.css b/blocks/inventory/delete/delete.css new file mode 100644 index 00000000..791ac9d0 --- /dev/null +++ b/blocks/inventory/delete/delete.css @@ -0,0 +1,77 @@ +:host { + display: contents; +} + +.list { + margin: 0; + padding: 0 0 0 var(--s2-spacing-300); + list-style: disc; + list-style-position: outside; + max-height: 8rem; + overflow-y: auto; + overflow-wrap: anywhere; + line-height: 1.43; +} + +.list li + li { + margin-top: var(--s2-spacing-100); +} + +.hint { + margin: var(--s2-spacing-200) 0 0; + color: var(--s2-gray-700); + font-size: var(--s2-body-size-xs); +} + +.btn { + display: inline-flex; + align-items: center; + gap: var(--s2-spacing-75); + height: 32px; + padding: 0 var(--s2-spacing-300); + border: 1px solid transparent; + border-radius: var(--s2-corner-radius-400); + font-family: var(--s2-font-family); + font-size: var(--s2-component-m-bold-font-size); + font-weight: var(--s2-component-m-bold-font-weight); + cursor: pointer; + + &:disabled { + opacity: 0.6; + cursor: default; + } +} + +.btn-secondary { + background: transparent; + border-color: var(--s2-gray-300); + color: var(--s2-gray-800); + + &:hover:not(:disabled) { + background: var(--s2-gray-75); + } +} + +.btn-danger { + background: var(--s2-red-900); + color: #fff; + + &:hover:not(:disabled) { + background: var(--s2-red-1000); + } +} + +.spinner { + display: block; + width: 14px; + height: 14px; + border: 2px solid rgb(255 255 255 / 30%); + border-top-color: currentcolor; + border-radius: 50%; + animation: delete-spin 0.7s linear infinite; + flex-shrink: 0; +} + +@keyframes delete-spin { + to { transform: rotate(360deg); } +} diff --git a/blocks/inventory/delete/delete.js b/blocks/inventory/delete/delete.js new file mode 100644 index 00000000..a67c37d7 --- /dev/null +++ b/blocks/inventory/delete/delete.js @@ -0,0 +1,102 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { getNx } from '../../../scripts/utils.js'; +import { deleteSourcePath } from '../browse-api.js'; + +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); +await import(`${getNx()}/blocks/shared/dialog/dialog.js`); +const styles = await loadStyle(import.meta.url); + +class NxInventoryDeleteDialog extends LitElement { + static properties = { + selectedRows: { type: Array }, + _isPending: { state: true }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [styles]; + } + + _emitComplete(detail = {}) { + this.dispatchEvent(new CustomEvent('nx-browse-action-complete', { + detail, + bubbles: true, + composed: true, + })); + } + + _handleCancel = () => { this._emitComplete(); }; + + _handleClose = () => { this._emitComplete(); }; + + _handleConfirm = async () => { + const { selectedRows } = this; + if (!selectedRows?.length) { + this._emitComplete(); + return; + } + + this._isPending = true; + try { + for (const item of selectedRows) { + const result = await deleteSourcePath(item.path); + if (!result.ok) { + this._emitComplete({ message: result.error || 'Delete failed' }); + return; + } + } + this._emitComplete({ success: true }); + } catch { + this._emitComplete({ message: 'An unexpected error occurred.' }); + } finally { + this._isPending = false; + } + }; + + render() { + const selectedRows = this.selectedRows ?? []; + if (!selectedRows.length) return nothing; + + const count = selectedRows.length; + const itemWord = count === 1 ? 'item' : 'items'; + const lines = selectedRows.map((item) => item.path).slice(0, 5); + const more = count > 5 ? count - 5 : 0; + + return html` + +
    +
      + ${lines.map((path) => html`
    • ${path}
    • `)} +
    + ${more > 0 ? html`

    …and ${more} more

    ` : nothing} +
    + + +
    + `; + } +} + +if (!customElements.get('nx-inventory-delete-dialog')) { + customElements.define('nx-inventory-delete-dialog', NxInventoryDeleteDialog); +} diff --git a/blocks/inventory/inventory.css b/blocks/inventory/inventory.css new file mode 100644 index 00000000..a0d10b88 --- /dev/null +++ b/blocks/inventory/inventory.css @@ -0,0 +1,133 @@ +:host { + --browse-sheet-max-width: 1024px; + + display: flex; + flex-direction: column; + box-sizing: border-box; + height: calc(100dvh - var(--s2-nav-height)); + min-width: 0; + padding: var(--s2-spacing-200) var(--s2-spacing-300); + overflow: hidden; + font-family: var(--s2-font-family); + font-size: var(--s2-body-size-s); + line-height: var(--s2-body-line-height); + color: var(--s2-gray-800); + background-color: var(--s2-gray-25); + overscroll-behavior: contain; + + nx-browse-list { + flex: 1 1 0; + min-height: 0; + min-width: 0; + } + + .browse-bar { + display: flex; + align-items: center; + flex-shrink: 0; + height: 40px; + padding: 0 var(--s2-spacing-100); + + path { + fill: currentcolor; + } + } + + .browse-panel-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + border-radius: var(--s2-corner-radius-400); + background: transparent; + color: var(--s2-gray-800); + cursor: pointer; + + img { + display: block; + width: 16px; + height: 16px; + flex-shrink: 0; + } + + &:hover { + background-color: var(--s2-gray-75); + } + } + + .browse-hint { + margin: 0 var(--s2-spacing-300); + padding: var(--s2-spacing-200); + border-radius: var(--s2-corner-radius-300); + background-color: var(--s2-gray-75); + } + + .browse-hint-title { + margin: 0 0 var(--s2-spacing-100); + font-weight: var(--s2-component-s-medium-font-weight); + font-size: var(--s2-component-s-medium-font-size); + line-height: var(--s2-component-s-medium-line-height); + color: var(--s2-gray-900); + } + + .browse-hint-detail { + margin: 0; + color: var(--s2-gray-700); + } + + .browse-hint-error { + background-color: var(--s2-red-200); + } + + .browse-hint-error .browse-hint-title { + color: var(--s2-red-1100); + } + + .browse-hint-error .browse-hint-detail { + color: var(--s2-red-1000); + } + + .browse-header { + --browse-title-bar-height: var(--s2-spacing-500); + + position: relative; + flex-shrink: 0; + box-sizing: border-box; + width: 100%; + max-width: var(--browse-sheet-max-width); + margin: 0 auto var(--s2-spacing-300); + + nx-browse-action-bar { + position: absolute; + top: 0; + left: 0; + right: 0; + height: var(--browse-title-bar-height); + } + } + + .browse-title-bar { + display: flex; + align-items: center; + gap: var(--s2-spacing-100); + box-sizing: border-box; + min-width: 0; + height: var(--browse-title-bar-height); + margin-bottom: var(--s2-spacing-100); + } + + .browse-title { + flex: 1 1 auto; + margin: 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--s2-heading-size-m); + line-height: 26px; + font-weight: var(--s2-component-m-bold-font-weight); + color: var(--s2-gray-900); + } +} diff --git a/blocks/inventory/inventory.js b/blocks/inventory/inventory.js new file mode 100644 index 00000000..0bacc0ef --- /dev/null +++ b/blocks/inventory/inventory.js @@ -0,0 +1,350 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { getNx } from '../../scripts/utils.js'; +import { listFolder, itemHashPath } from '../shared/daFiles.js'; +import { + contextToPathContext, + entryTypeFromExtension, + isFolder, + RESOURCE_TYPE, +} from './utils.js'; +import './list/list.js'; +import './action-bar/action-bar.js'; +import './delete/delete.js'; +import { deploy, getItemPreviewUrl, renameItem } from './browse-api.js'; + +const { loadStyle, hashChange } = await import(`${getNx()}/utils/utils.js`); +const { showToast, VARIANT_ERROR } = await import(`${getNx()}/blocks/shared/toast/toast.js`); +const { getPanelStore, openPanel } = await import(`${getNx()}/utils/panel.js`); + +await import(`${getNx()}/blocks/shared/breadcrumb/breadcrumb.js`); + +const styles = await loadStyle(import.meta.url); +const PANEL_ICON_SRC = '/blocks/canvas/img/s2-icon-splitleft-20-n.svg'; + +const documentLayoutStyles = await loadStyle( + new URL('overrides.css', import.meta.url).href, +); +document.adoptedStyleSheets = [...document.adoptedStyleSheets, documentLayoutStyles]; + +class NxBrowse extends LitElement { + static properties = { + _items: { state: true }, + _listError: { state: true }, + _selectedItems: { state: true }, + _pendingAction: { state: true }, + _activeAction: { state: true }, + }; + + set context(value) { + this._explicitContext = true; + this._context = value; + this.requestUpdate(); + if (this.isConnected) { + this._syncList(); + } + } + + _openPanel(position) { + this.dispatchEvent(new CustomEvent('nx-browse-open-panel', { + bubbles: true, + composed: true, + detail: { position }, + })); + } + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [styles]; + this._unsubscribeHash = hashChange.subscribe((hashState) => { + if (!this._explicitContext) { + this._context = hashState; + this._syncList(); + } + }); + if (this._explicitContext && this._context) { + this._syncList(); + } + } + + disconnectedCallback() { + this._unsubscribeHash?.(); + super.disconnectedCallback(); + } + + get _pathContext() { + return contextToPathContext(this._context); + } + + async _syncList() { + const ctx = this._pathContext; + if (!ctx) { + this._items = undefined; + this._listError = undefined; + this.requestUpdate(); + return; + } + + const { fullpath } = ctx; + + const result = await listFolder(fullpath); + + if ('error' in result) { + this._items = undefined; + this._listError = result.error; + } else { + this._listError = undefined; + this._items = result; + } + this.requestUpdate(); + } + + _onSelectionChange(event) { + this._selectedItems = event.detail?.selected ?? []; + } + + _clearSelection() { + this.shadowRoot.querySelector('nx-browse-list')?.clearSelection(); + } + + _onSelectionAction(event) { + const { action } = event.detail || {}; + if (!action) return; + if (action === 'rename') { + const { key, item } = this._selectedItems[0]; + this._activeAction = { type: 'rename', key, item }; + return; + } + if (action === 'delete') { + this._activeAction = { type: 'delete', selectedRows: this._selectedItems.map(({ item }) => item) }; + return; + } + if (action === 'copyLink') { + this._onCopyLink(); + return; + } + this._onDeploy(action); + } + + async _onRename(event) { + const { item, newName } = event.detail; + this._activeAction = null; + const { ok, error } = await renameItem(item, newName); + if (ok) { + this._clearSelection(); + this._syncList(); + } else { + showToast({ text: error ?? 'Rename failed', variant: VARIANT_ERROR }); + } + } + + _onRenameCancel() { + this._activeAction = null; + } + + async _onCopyLink() { + const urls = (this._selectedItems ?? []) + .map(({ item }) => getItemPreviewUrl(item)) + .filter(Boolean); + if (!urls.length) return; + try { + await navigator.clipboard.writeText(urls.join('\n')); + const count = urls.length; + showToast({ text: count === 1 ? 'Link copied' : `${count} links copied` }); + } catch { + showToast({ text: 'Could not copy to clipboard', variant: VARIANT_ERROR }); + } + } + + async _onDeploy(action) { + if (this._pendingAction) return; + this._pendingAction = action; + const { item } = this._selectedItems[0]; + const { ok, openedUrls } = await deploy(item.path, action); + this._pendingAction = null; + if (ok) openedUrls.forEach((url) => window.open(url, url)); + } + + _onActionComplete(event) { + const { success } = event.detail || {}; + this._activeAction = null; + if (success) { + this._clearSelection(); + this._syncList(); + } + } + + _onBrowseActivate(event) { + const { pathKey, item, shiftKey, ctrlKey } = event.detail || {}; + if (!item) return; + + if (isFolder(item)) { + window.location.hash = `#/${pathKey}`; + return; + } + + const url = new URL(window.location.href); + const entryType = entryTypeFromExtension(item.ext); + + if (entryType === RESOURCE_TYPE.document) { + url.pathname = '/canvas'; + url.hash = `#/${itemHashPath(item)}`; + if (ctrlKey) { + window.open(url.href, '_blank'); + } else if (shiftKey) { + window.open(url.href, '_blank', 'noopener,noreferrer'); + } else { + window.location.assign(url.href); + } + return; + } else if (entryType === RESOURCE_TYPE.sheet) { + url.pathname = '/sheet'; + url.hash = `#/${item.path.slice(1, -(item.ext.length + 1))}`; + } else { + url.pathname = '/media'; + url.hash = `#${item.path}`; + } + + url.search = ''; + window.open(url.href, '_blank', 'noopener,noreferrer'); + } + + render() { + const ctx = this._pathContext; + + const bar = html` +
    + +
    + `; + + if (!ctx) { + return html` + ${bar} +
    +

    Nothing to show here yet

    +

    + Choose a site or folder from your workspace to see files in this list. +

    +
    + `; + } + + const title = (ctx.pathSegments.at(-1) ?? '').split(/[?#]/)[0]; + + if (!this._listError && this._items === undefined) { + return bar; + } + + const header = html` +
    +
    +

    ${title}

    +
    + + ${this._selectedItems?.length > 0 ? html` + + ` : nothing} +
    + `; + + if (this._listError) { + return html` + ${bar} + ${header} + + `; + } + + const currentPathKey = ctx.pathSegments.join('/'); + + return html` + ${bar} + ${header} + + ${this._activeAction?.type === 'delete' ? html` + + ` : nothing} + `; + } +} + +if (!customElements.get('nx-browse')) { + customElements.define('nx-browse', NxBrowse); +} + +export default function decorate(block) { + block.textContent = ''; + const browse = document.createElement('nx-browse'); + block.append(browse); + + const openBrowseChatPanel = () => { + const store = getPanelStore(); + const width = store.before?.width ?? '400px'; + openPanel({ + position: 'before', + width, + getContent: async () => { + await import(`${getNx()}/blocks/chat/chat.js`); + return document.createElement('nx-chat'); + }, + }); + }; + + browse.addEventListener('nx-browse-open-panel', (e) => { + if (e.detail.position === 'before') openBrowseChatPanel(); + }); + + let prevKeys = new Set(); + browse.addEventListener('nx-browse-selection-change', ({ detail }) => { + const selected = detail?.selected ?? []; + const nextKeys = new Set(selected.map(({ key }) => key)); + for (const key of prevKeys) { + if (!nextKeys.has(key)) { + document.dispatchEvent(new CustomEvent('nx-add-to-chat', { detail: { key: `browse-${key}` } })); + } + } + for (const { key, item } of selected) { + if (!prevKeys.has(key)) { + const label = item.ext ? `${item.name}.${item.ext}` : item.name; + document.dispatchEvent(new CustomEvent('nx-add-to-chat', { + detail: { + key: `browse-${key}`, + id: key, + label, + blockName: label, + innerText: `Selected repository path: ${key}`, + }, + })); + } + } + prevKeys = nextKeys; + }); + + const store = getPanelStore(); + if (store.before) openBrowseChatPanel(); +} diff --git a/blocks/inventory/list/format.js b/blocks/inventory/list/format.js new file mode 100644 index 00000000..94818da6 --- /dev/null +++ b/blocks/inventory/list/format.js @@ -0,0 +1,66 @@ +const TIME_FORMAT_OPTIONS = { hour: 'numeric', minute: '2-digit' }; + +function parseTimestamp(timestampRaw) { + if (timestampRaw == null || timestampRaw === '') return null; + const parsedDate = new Date(timestampRaw); + return Number.isNaN(parsedDate.getTime()) ? null : parsedDate; +} + +function formatAbsolute(dateInstant, referenceNow = new Date()) { + const timeSegment = dateInstant.toLocaleTimeString(undefined, TIME_FORMAT_OPTIONS); + if (dateInstant.getFullYear() === referenceNow.getFullYear()) { + const dateSegment = dateInstant.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + }); + return `${dateSegment}, ${timeSegment}`; + } + const dateSegment = dateInstant.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + return `${dateSegment}, ${timeSegment}`; +} + +function formatRelative(dateInstant, referenceNow = new Date()) { + const elapsedMilliseconds = referenceNow - dateInstant; + const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); + const eventCalendarDay = dateInstant.toDateString(); + const referenceCalendarDay = referenceNow.toDateString(); + + let displayLabel; + if (eventCalendarDay === referenceCalendarDay) { + const elapsedSeconds = Math.floor(elapsedMilliseconds / 1000); + if (elapsedSeconds < 60) displayLabel = relativeTimeFormatter.format(0, 'second'); + else { + const elapsedMinutes = Math.floor(elapsedSeconds / 60); + if (elapsedMinutes < 60) displayLabel = relativeTimeFormatter.format(-elapsedMinutes, 'minute'); + else { + const elapsedHours = Math.floor(elapsedMinutes / 60); + displayLabel = relativeTimeFormatter.format(-elapsedHours, 'hour'); + } + } + } else { + const referenceYesterday = new Date(referenceNow); + referenceYesterday.setDate(referenceYesterday.getDate() - 1); + if (eventCalendarDay === referenceYesterday.toDateString()) { + const relativeDayPhrase = relativeTimeFormatter.format(-1, 'day'); + const timeSegment = dateInstant.toLocaleTimeString(undefined, TIME_FORMAT_OPTIONS); + displayLabel = `${relativeDayPhrase}, ${timeSegment}`; + } else { + displayLabel = formatAbsolute(dateInstant, referenceNow); + } + } + + return displayLabel; +} + +export function formatColumnLastModified(lastModified) { + const lastModifiedDate = parseTimestamp(lastModified); + if (!lastModifiedDate) return { label: null }; + return { + label: formatRelative(lastModifiedDate), + title: `Last modified on ${formatAbsolute(lastModifiedDate)}`, + }; +} diff --git a/blocks/inventory/list/list.css b/blocks/inventory/list/list.css new file mode 100644 index 00000000..81429299 --- /dev/null +++ b/blocks/inventory/list/list.css @@ -0,0 +1,276 @@ +:host { + display: block; + min-height: 0; + min-width: 0; + overflow-y: auto; + overscroll-behavior-y: contain; + -webkit-overflow-scrolling: touch; + background-color: var(--s2-gray-25); + font-family: var(--s2-font-family); + font-size: var(--s2-component-s-regular-font-size); + line-height: var(--s2-component-s-regular-line-height); + color: var(--s2-gray-900); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; + pointer-events: none; +} + +.sheet { + --rule: var(--s2-gray-200); + + box-sizing: border-box; + width: 100%; + max-width: var(--browse-sheet-max-width, 1024px); + margin-inline: auto; + table-layout: fixed; + border-collapse: separate; + border-spacing: 0; + font-size: var(--s2-component-m-regular-font-size); + line-height: var(--s2-component-m-regular-line-height); + font-weight: var(--s2-component-m-medium-font-weight); + + & thead { + position: sticky; + top: 0; + z-index: 1; + background-color: var(--s2-gray-25); + } + + & :is(th, td) { + box-sizing: border-box; + vertical-align: middle; + } + + & th { + text-align: left; + white-space: nowrap; + color: var(--s2-gray-700); + } + + & thead th { + height: var(--s2-spacing-500); + font-weight: var(--s2-component-m-medium-font-weight); + + /* Row-group borders are unreliable with `border-collapse: separate`. Inset + shadow draws the rule inside the cell so `height` is not grown by 1px like + `border-bottom` on table cells. */ + background-color: var(--s2-gray-25); + box-shadow: inset 0 -1px 0 0 var(--rule); + } + + & tbody td { + height: var(--s2-spacing-700); + padding: 0; + } + + & tbody { + & td { + box-shadow: inset 0 -1px 0 0 var(--rule); + } + + & td.column-modified { + white-space: nowrap; + } + + & tr:last-child td { + border-bottom: none; + } + + & tr.row { + &[aria-selected="true"] { + background-color: var(--s2-blue-100); + } + + &:hover { + background-color: var(--s2-gray-75); + } + + &[aria-selected="true"]:hover { + background-color: var(--s2-blue-100); + } + } + + & tr.row-dir, + & tr.row-file { + cursor: pointer; + } + } + + & :is(th, td).column-selection { + box-sizing: border-box; + width: 40px; + max-width: 44px; + text-align: center; + } + + & :is(th, td).column-entry-type { + box-sizing: border-box; + width: 32px; + max-width: 32px; + padding-inline: var(--s2-spacing-75); + text-align: end; + } + + & :is(th, td).column-file-name { + width: auto; + min-width: 0; + padding: 0 var(--s2-spacing-200) 0 var(--s2-spacing-75); + } + + & :is(th, td).column-modified { + box-sizing: border-box; + width: 12.5rem; + max-width: 12.5rem; + padding: 0 var(--s2-spacing-200) 0 var(--s2-spacing-75); + } + + .check { + margin: 0; + cursor: pointer; + vertical-align: middle; + + & input { + appearance: none; + box-sizing: border-box; + width: var(--s2-body-size-s); + height: var(--s2-body-size-s); + margin: 0; + flex-shrink: 0; + border: 2px solid var(--s2-gray-900); + border-radius: var(--s2-corner-radius-75); + background-color: var(--s2-gray-25); + cursor: pointer; + } + + & input:checked { + border-color: var(--s2-blue-900); + background-color: var(--s2-blue-900); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none'%3E%3Cpath stroke='%23fff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M2.5 6l2.5 3 4.5-5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: center; + background-size: 9px 9px; + } + + & input:indeterminate { + background-color: var(--s2-gray-25); + border-color: var(--s2-gray-900); + background-image: linear-gradient(var(--s2-gray-900), var(--s2-gray-900)); + background-size: 7px 2px; + background-position: center; + background-repeat: no-repeat; + } + + & input:focus-visible { + outline: 2px solid var(--s2-blue-900); + outline-offset: 2px; + } + } + + /* Fill the select column so the whole hit area toggles the checkbox (label wraps input). */ + & :is(th, td).column-selection .check { + display: flex; + box-sizing: border-box; + width: 100%; + height: 100%; + min-height: var(--s2-spacing-700); + align-items: center; + justify-content: center; + margin: 0; + cursor: pointer; + vertical-align: middle; + } + + & thead th.column-selection .check { + min-height: var(--s2-spacing-500); + } + + .column-entry-type img { + display: block; + width: 18px; + height: 18px; + margin-inline-start: auto; + } + + & .sort-btn { + display: inline-flex; + align-items: center; + gap: var(--s2-spacing-75); + padding: 0; + border: none; + background: none; + font: inherit; + color: inherit; + cursor: pointer; + white-space: nowrap; + + &:hover { + color: var(--s2-gray-900); + } + } + + & .sort-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + opacity: 0; + transition: transform 0.15s ease, opacity 0.15s ease; + flex-shrink: 0; + + & svg { + display: block; + width: 100%; + height: 100%; + } + + & :is(path, circle, rect, ellipse, line, polyline, polygon) { + fill: currentcolor; + } + } + + & th[aria-sort="ascending"] .sort-indicator, + & th[aria-sort="descending"] .sort-indicator { + opacity: 1; + } + + & .sort-indicator-desc { + transform: rotate(180deg); + } + + & .filename { + display: block; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--s2-gray-900); + font-weight: var(--s2-component-m-bold-font-weight); + } + + & .rename-input { + display: block; + width: 100%; + min-width: 0; + font: inherit; + font-weight: var(--s2-component-m-bold-font-weight); + color: var(--s2-gray-900); + background: transparent; + border: none; + border-bottom: 2px solid var(--s2-blue-900); + border-radius: 0; + outline: none; + padding: 0; + white-space: nowrap; + } +} diff --git a/blocks/inventory/list/list.js b/blocks/inventory/list/list.js new file mode 100644 index 00000000..51b530df --- /dev/null +++ b/blocks/inventory/list/list.js @@ -0,0 +1,288 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { getNx } from '../../../scripts/utils.js'; +import { formatColumnLastModified } from './format.js'; +import { + getIconByExtension, + isFolder, + itemRowPathKey, + ICON_URLS, +} from '../utils.js'; + +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); +const { loadHrefSvg, ICONS_BASE } = await import(`${getNx()}/utils/svg.js`); + +const styles = await loadStyle(import.meta.url); +const sortArrowSvg = await loadHrefSvg(`${ICONS_BASE}S2_Icon_ArrowUpSend_20_N.svg`); + +/** `''` stays empty (e.g. folders); `null` / `undefined` → em dash for missing data. */ +function browseCellText(label) { + if (label === '') return ''; + return label ?? '—'; +} + +export class NxBrowseList extends LitElement { + static properties = { + items: { type: Array }, + currentPathKey: { type: String, attribute: 'current-path-key' }, + renamingKey: { attribute: false }, + _selectedKeys: { state: true }, + _sort: { state: true }, + }; + + willUpdate(changedProperties) { + if (changedProperties.has('currentPathKey')) { + this._selectedKeys = []; + this._emitSelectionChange(); + } + } + + updated(changed) { + if (changed.has('renamingKey') && this.renamingKey) { + const input = this.shadowRoot?.querySelector('.rename-input'); + if (input) { + input.focus(); + input.select(); + } + } + const input = this.shadowRoot?.getElementById('select-all'); + if (!(input instanceof HTMLInputElement)) { + return; + } + if (this.items === undefined) { + return; + } + const { items } = this; + const selectedKeys = this._selectedKeys ?? []; + const keys = items.map((item) => itemRowPathKey(this.currentPathKey, item)); + const selectedCount = keys.filter((rowKey) => selectedKeys.includes(rowKey)).length; + input.indeterminate = selectedCount > 0 && selectedCount < keys.length; + if (keys.length === 0) { + input.checked = false; + input.indeterminate = false; + } + } + + get _sortedItems() { + if (!this.items || !this._sort) return this.items; + const { col, dir } = this._sort; + return [...this.items].sort((a, b) => { + const av = a[col] ?? ''; + const bv = b[col] ?? ''; + if (av > bv) return dir === 'asc' ? 1 : -1; + if (av < bv) return dir === 'asc' ? -1 : 1; + return 0; + }); + } + + _onSortColumn(col) { + const dir = this._sort?.col === col && this._sort.dir === 'asc' ? 'desc' : 'asc'; + this._sort = { col, dir }; + } + + _ariaSort(col) { + if (this._sort?.col !== col) return nothing; + return this._sort.dir === 'asc' ? 'ascending' : 'descending'; + } + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [styles]; + } + + _renderSortIcon(col) { + const dirClass = this._sort?.col === col ? this._sort.dir : 'none'; + return html``; + } + + _renderIcon(iconKey) { + const src = ICON_URLS[iconKey]; + return src ? html`` : nothing; + } + + _onRowActivate(event, item) { + event.stopPropagation(); + this.dispatchEvent( + new CustomEvent('nx-browse-activate', { + detail: { + pathKey: itemRowPathKey(this.currentPathKey, item), + item, + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey || event.metaKey, + }, + bubbles: true, + composed: true, + }), + ); + } + + _emitSelectionChange() { + const selectedKeys = [...(this._selectedKeys ?? [])]; + const selected = (this.items ?? []) + .map((item) => ({ key: itemRowPathKey(this.currentPathKey, item), item })) + .filter(({ key }) => selectedKeys.includes(key)); + this.dispatchEvent( + new CustomEvent('nx-browse-selection-change', { + detail: { selected }, + bubbles: true, + composed: true, + }), + ); + } + + clearSelection() { + this._selectedKeys = []; + this._emitSelectionChange(); + } + + _onRenameKeydown(e, item) { + if (e.key === 'Enter') { + e.preventDefault(); + const newName = e.target.value.trim(); + if (newName && newName !== item.name) { + this.dispatchEvent(new CustomEvent('nx-browse-rename', { + detail: { item, newName }, + bubbles: true, + composed: true, + })); + } else { + this._onRenameBlur(); + } + } else if (e.key === 'Escape') { + this._onRenameBlur(); + } + } + + _onRenameBlur() { + this.dispatchEvent(new CustomEvent('nx-browse-rename-cancel', { bubbles: true, composed: true })); + } + + _isRowSelected(key) { + return (this._selectedKeys ?? []).includes(key); + } + + _onSelectAllChange(event) { + event.stopPropagation(); + const input = event.target; + if (!(input instanceof HTMLInputElement)) { + return; + } + if (this.items === undefined) { + return; + } + const { items } = this; + const keys = items.map((item) => itemRowPathKey(this.currentPathKey, item)); + this._selectedKeys = input.checked ? [...keys] : []; + this._emitSelectionChange(); + } + + _onRowCheckboxChange(event, item) { + event.stopPropagation(); + const input = event.target; + if (!(input instanceof HTMLInputElement)) { + return; + } + const key = itemRowPathKey(this.currentPathKey, item); + const selectedKeys = this._selectedKeys ?? []; + if (input.checked) { + this._selectedKeys = selectedKeys.includes(key) + ? selectedKeys + : [...selectedKeys, key]; + } else { + this._selectedKeys = selectedKeys.filter((selectedKey) => selectedKey !== key); + } + this._emitSelectionChange(); + } + + render() { + if (this.items === undefined) { + return nothing; + } + const items = this._sortedItems; + const selectedKeys = this._selectedKeys ?? []; + const rowKeys = items.map((item) => itemRowPathKey(this.currentPathKey, item)); + const selectedCount = rowKeys.filter((rowKey) => selectedKeys.includes(rowKey)).length; + const allSelected = items.length > 0 && selectedCount === items.length; + + return html` + + + + + + + + + + + ${items.map((item) => { + const key = itemRowPathKey(this.currentPathKey, item); + const selected = this._isRowSelected(key); + const folder = isFolder(item); + const modified = folder + ? { label: '' } + : formatColumnLastModified(item.lastModified); + const rowKind = folder ? 'row-dir' : 'row-file'; + return html` + this._onRowActivate(event, item)} + > + + + + + + `; + })} + +
    + + Type + + + +
    event.stopPropagation()}> + + ${this._renderIcon(getIconByExtension(item?.ext))} e.stopPropagation() : nothing}> + ${key === this.renamingKey ? html` + this._onRenameKeydown(e, item)} + @blur=${() => this._onRenameBlur()} + > + ` : html` + ${item.name} + `} + + ${browseCellText(modified.label)} +
    + `; + } +} + +if (!customElements.get('nx-browse-list')) { + customElements.define('nx-browse-list', NxBrowseList); +} diff --git a/blocks/inventory/overrides.css b/blocks/inventory/overrides.css new file mode 100644 index 00000000..42d451b0 --- /dev/null +++ b/blocks/inventory/overrides.css @@ -0,0 +1,10 @@ +/* Prevent main from page-scrolling when browse is active */ +body:has(nx-browse) { + overflow: hidden; +} + +/* Hide the toggle when the before panel is already visible */ +html:has(aside.panel[data-position="before"]:not([hidden])) + nx-browse::part(toggle-before) { + display: none; +} diff --git a/blocks/inventory/utils.js b/blocks/inventory/utils.js new file mode 100644 index 00000000..87797f58 --- /dev/null +++ b/blocks/inventory/utils.js @@ -0,0 +1,84 @@ +export function parseRepoPath(fullpath) { + const trimmed = (fullpath || '').trim(); + const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + const parts = normalized.slice(1).split('/').filter(Boolean); + if (parts.length < 2) return null; + const [org, site, ...rest] = parts; + const pathSegments = [org, site, ...rest]; + return { + org, + site, + pathSegments, + fullpath: `/${pathSegments.join('/')}`, + contentPath: rest.join('/'), + }; +} + +export function contextToPathContext(context) { + if (!context) return null; + const { org, site, path } = context; + if (!org || !site) return null; + const base = `/${org}/${site}`; + const fullpath = path ? `${base}/${path.split('/').filter(Boolean).join('/')}` : base; + const parsed = parseRepoPath(fullpath); + return parsed ? { pathSegments: parsed.pathSegments, fullpath: parsed.fullpath } : null; +} + +export function itemRowPathKey(folderPathKey, item) { + const name = item.name || ''; + return folderPathKey ? `${folderPathKey}/${name}` : name; +} + +/** Whether the list API row is a folder (no non-empty `ext`); files include `ext`. */ +export function isFolder(row) { + return row?.ext == null || String(row.ext).trim() === ''; +} + +/** Resource kind from extension (icons, list behavior). */ +export const RESOURCE_TYPE = Object.freeze({ + folder: 'folder', + document: 'document', + media: 'media', + sheet: 'sheet', + file: 'file', +}); + +export const ICON_URLS = { + folder: '/img/icons/s2-icon-folder-20-n.svg', + fileText: '/img/icons/s2-icon-filetext-20-n.svg', + image: '/img/icons/s2-icon-image-20-n.svg', + table: '/img/icons/s2-icon-table-20-n.svg', +}; + +export function entryTypeFromExtension(ext) { + if (ext == null || ext === '') { + return RESOURCE_TYPE.folder; + } + const e = String(ext).replace(/^\./, '').toLowerCase(); + if (['html', 'htm'].includes(e)) { + return RESOURCE_TYPE.document; + } + if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'mp4', 'webm', 'mov'].includes(e)) { + return RESOURCE_TYPE.media; + } + if (['json', 'xlsx', 'xls', 'csv'].includes(e)) { + return RESOURCE_TYPE.sheet; + } + return RESOURCE_TYPE.file; +} + +export function getIconByExtension(ext) { + switch (entryTypeFromExtension(ext)) { + case RESOURCE_TYPE.folder: + return 'folder'; + case RESOURCE_TYPE.document: + return 'fileText'; + case RESOURCE_TYPE.media: + return 'image'; + case RESOURCE_TYPE.sheet: + return 'table'; + case RESOURCE_TYPE.file: + default: + return 'fileText'; + } +} diff --git a/blocks/shared/daFiles.js b/blocks/shared/daFiles.js new file mode 100644 index 00000000..783e84a1 --- /dev/null +++ b/blocks/shared/daFiles.js @@ -0,0 +1,27 @@ +import { getNx } from '../../scripts/utils.js'; +import { daFetch } from './utils.js'; + +const { DA_ADMIN } = await import(`${getNx()}/utils/utils.js`); + +export async function listFolder(fullpath) { + let response; + try { + response = await daFetch(`${DA_ADMIN}/list${fullpath}`); + } catch (err) { + return { error: err instanceof Error ? err.message : 'List request failed', status: 0 }; + } + if (!response.ok) return { error: `List failed: ${response.status}`, status: response.status }; + try { + const payload = await response.json(); + if (!Array.isArray(payload)) return { error: 'Invalid list response', status: response.status }; + return payload; + } catch { + return { error: 'Invalid response body', status: response.status }; + } +} + +export function itemHashPath(item) { + if (!item?.path) return ''; + if (!item.ext) return item.path.replace(/^\//, ''); + return item.path.slice(1, -(item.ext.length + 1)); +} diff --git a/img/icons/s2-icon-add-20-n.svg b/img/icons/s2-icon-add-20-n.svg new file mode 100644 index 00000000..0bcb6cab --- /dev/null +++ b/img/icons/s2-icon-add-20-n.svg @@ -0,0 +1,3 @@ + + + diff --git a/img/icons/s2-icon-aichat-20-n.svg b/img/icons/s2-icon-aichat-20-n.svg new file mode 100644 index 00000000..001abdae --- /dev/null +++ b/img/icons/s2-icon-aichat-20-n.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/img/icons/s2-icon-arrowupsend-20-n.svg b/img/icons/s2-icon-arrowupsend-20-n.svg new file mode 100644 index 00000000..35f2e0aa --- /dev/null +++ b/img/icons/s2-icon-arrowupsend-20-n.svg @@ -0,0 +1,3 @@ + + + diff --git a/img/icons/s2-icon-checkmark-20-n.svg b/img/icons/s2-icon-checkmark-20-n.svg new file mode 100644 index 00000000..424d2dbb --- /dev/null +++ b/img/icons/s2-icon-checkmark-20-n.svg @@ -0,0 +1,3 @@ + + + diff --git a/img/icons/s2-icon-chevronup-20-n.svg b/img/icons/s2-icon-chevronup-20-n.svg new file mode 100644 index 00000000..190284b9 --- /dev/null +++ b/img/icons/s2-icon-chevronup-20-n.svg @@ -0,0 +1,3 @@ + + + diff --git a/img/icons/s2-icon-close-20-n.svg b/img/icons/s2-icon-close-20-n.svg new file mode 100644 index 00000000..eec3dc69 --- /dev/null +++ b/img/icons/s2-icon-close-20-n.svg @@ -0,0 +1,3 @@ + + + diff --git a/img/icons/s2-icon-delete-20-n.svg b/img/icons/s2-icon-delete-20-n.svg new file mode 100644 index 00000000..a8714278 --- /dev/null +++ b/img/icons/s2-icon-delete-20-n.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/img/icons/s2-icon-openin-20-n.svg b/img/icons/s2-icon-openin-20-n.svg new file mode 100644 index 00000000..766a32e4 --- /dev/null +++ b/img/icons/s2-icon-openin-20-n.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/img/icons/s2-icon-paste-20-n.svg b/img/icons/s2-icon-paste-20-n.svg new file mode 100644 index 00000000..4f14e7b2 --- /dev/null +++ b/img/icons/s2-icon-paste-20-n.svg @@ -0,0 +1,4 @@ + + + + diff --git a/img/icons/s2-icon-preview-20-n.svg b/img/icons/s2-icon-preview-20-n.svg new file mode 100644 index 00000000..8cbc266c --- /dev/null +++ b/img/icons/s2-icon-preview-20-n.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/img/icons/s2-icon-publish-20-n.svg b/img/icons/s2-icon-publish-20-n.svg new file mode 100644 index 00000000..a03a4621 --- /dev/null +++ b/img/icons/s2-icon-publish-20-n.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/img/icons/s2-icon-removecircle-20-n.svg b/img/icons/s2-icon-removecircle-20-n.svg new file mode 100644 index 00000000..f16b9653 --- /dev/null +++ b/img/icons/s2-icon-removecircle-20-n.svg @@ -0,0 +1,4 @@ + + + + diff --git a/img/icons/s2-icon-search-20-n.svg b/img/icons/s2-icon-search-20-n.svg new file mode 100644 index 00000000..ced34c88 --- /dev/null +++ b/img/icons/s2-icon-search-20-n.svg @@ -0,0 +1,3 @@ + + + diff --git a/img/icons/s2-icon-share-20-n.svg b/img/icons/s2-icon-share-20-n.svg new file mode 100644 index 00000000..b3bf9c9a --- /dev/null +++ b/img/icons/s2-icon-share-20-n.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/img/icons/s2-icon-splitleft-20-n.svg b/img/icons/s2-icon-splitleft-20-n.svg new file mode 100644 index 00000000..1fd6ef8d --- /dev/null +++ b/img/icons/s2-icon-splitleft-20-n.svg @@ -0,0 +1,3 @@ + + + diff --git a/img/icons/s2-icon-splitright-20-n.svg b/img/icons/s2-icon-splitright-20-n.svg new file mode 100644 index 00000000..4fd5b12b --- /dev/null +++ b/img/icons/s2-icon-splitright-20-n.svg @@ -0,0 +1,3 @@ + + + diff --git a/img/icons/s2-icon-stop-20-n.svg b/img/icons/s2-icon-stop-20-n.svg new file mode 100644 index 00000000..4ce6b01d --- /dev/null +++ b/img/icons/s2-icon-stop-20-n.svg @@ -0,0 +1,3 @@ + + +