diff --git a/docs/_layouts/view.html b/docs/_layouts/view.html index ebe918b9..f3be90c2 100644 --- a/docs/_layouts/view.html +++ b/docs/_layouts/view.html @@ -8,7 +8,7 @@ - + diff --git a/docs/dist/v1/chartifact-toolbar.css b/docs/dist/v1/chartifact-toolbar.css index 2e4e8848..402c025c 100644 --- a/docs/dist/v1/chartifact-toolbar.css +++ b/docs/dist/v1/chartifact-toolbar.css @@ -58,17 +58,17 @@ } /* Default: stack toolbar sections vertically */ -.chartifact-toolbar > div { +.chartifact-toolbar .toolbar-item { margin: 6px 0; } /* Wide screens: layout horizontally */ @media (min-width: 1000px) { - .chartifact-toolbar { + .chartifact-toolbar .toolbar-group { display: flex; justify-content: space-between; } - .chartifact-toolbar > div { + .chartifact-toolbar .toolbar-item { display: inline-block; margin: 0; } diff --git a/docs/dist/v1/chartifact.host.umd.js b/docs/dist/v1/chartifact.host.umd.js index e6f58d62..903ea0a3 100644 --- a/docs/dist/v1/chartifact.host.umd.js +++ b/docs/dist/v1/chartifact.host.umd.js @@ -2363,6 +2363,162 @@ ${guardedJs} return false; } } + const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; + function decamelize(str2, separator = "-") { + return str2.replace(/([a-z\d])([A-Z])/g, "$1" + separator + "$2").replace(/([A-Z]+)([A-Z][a-z\d]+)/g, "$1" + separator + "$2").toLowerCase(); + } + function createElement(tag, attrs, ...children) { + if (typeof tag === "function") { + const fn = tag; + let props = attrs; + if (props === null || props === void 0) { + props = { children }; + } else { + props.children = children; + } + return fn(props); + } else { + const ns = tag === "svg" ? SVG_NAMESPACE : null; + const el = ns ? document.createElementNS(ns, tag) : document.createElement(tag); + const map2 = attrs; + let ref; + for (let name in map2) { + if (name && map2.hasOwnProperty(name)) { + let value = map2[name]; + if (name === "className" && value !== void 0) { + setAttribute(el, ns, "class", value.toString()); + } else if (name === "disabled" && !value) ; + else if (value === null || value === void 0) { + continue; + } else if (value === true) { + setAttribute(el, ns, name, name); + } else if (typeof value === "function") { + if (name === "ref") { + ref = value; + } else { + el[name.toLowerCase()] = value; + } + } else if (typeof value === "object") { + setAttribute(el, ns, name, flatten(value)); + } else { + setAttribute(el, ns, name, value.toString()); + } + } + } + if (children && children.length > 0) { + appendChildren(el, children); + } + if (ref) { + ref(el); + } + return el; + } + } + function setAttribute(el, ns, name, value) { + if (ns) { + el.setAttributeNS(null, name, value); + } else { + el.setAttribute(name, value); + } + } + function flatten(o) { + const arr = []; + for (let prop in o) + arr.push(`${decamelize(prop, "-")}:${o[prop]}`); + return arr.join(";"); + } + function isInsideForeignObject(element) { + let current = element; + while (current) { + if (current.tagName.toLowerCase() === "foreignobject") { + return true; + } + current = current.parentElement; + } + return false; + } + function recreateWithSvgNamespace(element) { + const svgElement = document.createElementNS(SVG_NAMESPACE, element.tagName.toLowerCase()); + for (let i2 = 0; i2 < element.attributes.length; i2++) { + const attr = element.attributes[i2]; + svgElement.setAttributeNS(null, attr.name, attr.value); + } + const eventProperties = [ + "onclick", + "onmousedown", + "onmouseup", + "onmouseover", + "onmouseout", + "onmousemove", + "onkeydown", + "onkeyup", + "onkeypress", + "onfocus", + "onblur" + ]; + for (const prop of eventProperties) { + if (element[prop]) { + svgElement[prop] = element[prop]; + } + } + for (let i2 = 0; i2 < element.childNodes.length; i2++) { + const child = element.childNodes[i2]; + if (child.nodeType === Node.ELEMENT_NODE) { + svgElement.appendChild(recreateWithSvgNamespace(child)); + } else { + svgElement.appendChild(child.cloneNode(true)); + } + } + return svgElement; + } + function addChild(parentElement, child) { + if (child === null || child === void 0 || typeof child === "boolean") { + return; + } else if (Array.isArray(child)) { + appendChildren(parentElement, child); + } else if (isElement(child)) { + const childEl = child; + if (parentElement.namespaceURI === SVG_NAMESPACE && childEl.namespaceURI !== SVG_NAMESPACE && childEl.tagName.toLowerCase() !== "foreignobject" && !isInsideForeignObject(parentElement)) { + const recreated = recreateWithSvgNamespace(childEl); + parentElement.appendChild(recreated); + } else { + parentElement.appendChild(childEl); + } + } else { + parentElement.appendChild(document.createTextNode(child.toString())); + } + } + function appendChildren(parentElement, children) { + children.forEach((child) => addChild(parentElement, child)); + } + function isElement(el) { + return !!el.nodeType; + } + function mount(element, container) { + container.innerHTML = ""; + if (element) { + addChild(container, element); + } + } + const FolderDisplay = (props) => { + const { title, docIndex, docs, prevClick, nextClick } = props; + return /* @__PURE__ */ createElement("span", null, /* @__PURE__ */ createElement("span", null, title), /* @__PURE__ */ createElement("div", { style: { display: "inline-block", marginRight: "0.5em" } }), /* @__PURE__ */ createElement("span", null, "(document ", docIndex + 1, " of ", docs.length, ")"), /* @__PURE__ */ createElement("div", { style: { display: "inline-block" } }, /* @__PURE__ */ createElement("button", { type: "button", id: "prevBtn", disabled: docIndex === 0, onClick: prevClick }, "Previous"), /* @__PURE__ */ createElement( + "select", + { + id: "pageSelect", + title: `Select document (1 to ${docs.length})`, + onChange: (e) => props.pageSelectChange(parseInt(e.target.value, 10) - 1) + }, + docs.map((doc, index2) => /* @__PURE__ */ createElement( + "option", + { + key: index2, + value: index2 + 1 + }, + doc.title ? doc.title : `Page ${index2 + 1}` + )) + ), /* @__PURE__ */ createElement("button", { type: "button", id: "nextBtn", disabled: docIndex === docs.length - 1, onClick: nextClick }, "Next"))); + }; function loadFolder(folderUrl, folder, host) { if (!folder || !folder.docs) { host.errorHandler( @@ -2386,27 +2542,6 @@ ${guardedJs} return; } let docIndex = 0; - const { folderSpan } = host.toolbar; - folderSpan.style.display = ""; - const prevBtn = document.createElement("button"); - prevBtn.textContent = "Previous"; - prevBtn.disabled = docIndex === 0; - const nextBtn = document.createElement("button"); - nextBtn.textContent = "Next"; - nextBtn.disabled = docIndex === folder.docs.length - 1; - const pageSelect = document.createElement("select"); - for (let i2 = 0; i2 < folder.docs.length; i2++) { - const option = document.createElement("option"); - option.value = (i2 + 1).toString(); - option.textContent = folder.docs[i2].title ? folder.docs[i2].title : `Page ${i2 + 1}`; - pageSelect.appendChild(option); - } - pageSelect.value = (docIndex + 1).toString(); - const navDiv = document.createElement("div"); - navDiv.style.display = "inline-block"; - navDiv.appendChild(prevBtn); - navDiv.appendChild(pageSelect); - navDiv.appendChild(nextBtn); function getHashParam(key) { const params = new URLSearchParams(window.location.hash.slice(1)); return params.get(key) ?? void 0; @@ -2416,47 +2551,39 @@ ${guardedJs} params.set(key, value.toString()); window.location.hash = params.toString(); } - function updateFolderTitle() { - folderSpan.innerHTML = ""; - const label = document.createElement("span"); - label.textContent = `${folder.title} `; - const docCountDiv = document.createElement("div"); - docCountDiv.style.display = "inline-block"; - docCountDiv.style.marginRight = "0.5em"; - docCountDiv.textContent = `(document ${docIndex + 1} of ${folder.docs.length}) `; - folderSpan.appendChild(label); - folderSpan.appendChild(docCountDiv); - folderSpan.appendChild(navDiv); - } - updateFolderTitle(); + const props = { + title: folder.title, + docIndex, + docs: folder.docs, + prevClick: () => { + if (docIndex > 0) { + updatePage(docIndex - 1, true); + } + }, + nextClick: () => { + if (docIndex < folder.docs.length - 1) { + updatePage(docIndex + 1, true); + } + }, + pageSelectChange: (index2) => { + if (index2 >= 0 && index2 <= folder.docs.length - 1) { + updatePage(index2, true); + } + } + }; function updatePage(newDocIndex, setHash = false) { docIndex = newDocIndex; if (setHash) { setHashParam("page", docIndex + 1); } - prevBtn.disabled = docIndex === 0; - nextBtn.disabled = docIndex === folder.docs.length - 1; - pageSelect.value = (docIndex + 1).toString(); - updateFolderTitle(); + host.toolbar.addChildren(/* @__PURE__ */ createElement(FolderDisplay, { ...{ ...props, docIndex } })); + const pageSelect = document.querySelector("#pageSelect"); + if (pageSelect) { + pageSelect.value = (docIndex + 1).toString(); + } const title = folder.docs[docIndex].title || `Page ${docIndex + 1}`; resolveUrl(title, folderUrl, folder.docs[docIndex].href, host); } - pageSelect.onchange = () => { - const selectedPage = parseInt(pageSelect.value, 10); - if (selectedPage >= 1 && selectedPage <= folder.docs.length) { - updatePage(selectedPage - 1, true); - } - }; - prevBtn.onclick = () => { - if (docIndex > 0) { - updatePage(docIndex - 1, true); - } - }; - nextBtn.onclick = () => { - if (docIndex < folder.docs.length - 1) { - updatePage(docIndex + 1, true); - } - }; function goToPageFromHash() { const pageStr = getHashParam("page"); let newIndex = 0; @@ -2473,7 +2600,6 @@ ${guardedJs} setHashParam("page", docIndex + 1); } goToPageFromHash(); - folderSpan.appendChild(navDiv); } async function resolveUrl(title, base, relativeOrAbsolute, host) { let url; @@ -3140,112 +3266,132 @@ ${htmlJsonJs} htmlMarkdownWrapper, htmlJsonWrapper }; + const ToolbarElement = (props) => { + const { mode, restartClick, tweakClick, downloadClick, restartDisplay, tweakDisplay, downloadDisplay, downloadSource, downloadHtml, children } = props; + const { home, target } = window.location.hostname === "localhost" ? { home: "/", target: "_self" } : { home: "https://microsoft.github.io/", target: "_blank" }; + const displayMode = mode === "json" ? "json" : "markdown"; + return createElement( + "div", + { className: "toolbar-group", style: { backgroundColor: "inherit" } }, + createElement( + "div", + { className: "toolbar-item" }, + createElement("a", { href: `${home}chartifact/`, target }, "Chartifact"), + " viewer" + ), + createElement("div", { className: "toolbar-item", style: { display: children ? "" : "none" } }, children), + createElement( + "div", + { className: "toolbar-item" }, + createElement("button", { type: "button", id: "restart", style: { display: restartDisplay }, onClick: restartClick }, "start over"), + createElement("button", { type: "button", id: "tweak", style: { display: tweakDisplay }, onClick: tweakClick }, "view source"), + createElement("button", { type: "button", id: "download", style: { display: downloadDisplay }, onClick: downloadClick }, "download") + ), + createElement( + "div", + { id: "downloadPopup", style: { + position: "absolute", + display: "none", + padding: "12px 16px", + zIndex: 1, + backgroundColor: "inherit" + } }, + createElement("div", { style: { marginBottom: "8px" } }, "Download as:"), + createElement( + "ul", + null, + createElement( + "li", + null, + "Source (just the ", + displayMode, + " content)", + createElement("br", null), + createElement("button", { type: "button", id: "download-md", style: { marginRight: "8px" }, onClick: downloadSource }, "Source") + ), + createElement( + "li", + null, + "HTML wrapper (content plus a shareable viewer)", + createElement("br", null), + createElement("button", { type: "button", id: "download-html", onClick: downloadHtml }, "HTML wrapper") + ) + ) + ) + ); + }; class Toolbar { constructor(toolbarElementOrSelector, options = {}) { __publicField(this, "options"); __publicField(this, "toolbarElement"); - __publicField(this, "folderSpan"); - __publicField(this, "tweakButton"); - __publicField(this, "restartButton"); __publicField(this, "downloadButton"); + __publicField(this, "downloadPopup"); __publicField(this, "mode"); __publicField(this, "filename"); - __publicField(this, "downloadPopup"); - var _a, _b, _c, _d, _e; + __publicField(this, "props"); this.options = options; this.filename = options.filename || "sample"; - this.mode = options.mode || "markdown"; + const allowedModes = ["markdown", "json"]; + this.mode = allowedModes.includes(options.mode) ? options.mode : "markdown"; this.toolbarElement = typeof toolbarElementOrSelector === "string" ? document.querySelector(toolbarElementOrSelector) : toolbarElementOrSelector; if (!this.toolbarElement) { throw new Error("Toolbar element not found"); } - const { home, target } = window.location.hostname === "localhost" ? { home: "/", target: "_self" } : { home: "https://microsoft.github.io/", target: "_blank" }; - const html = ` -
- Chartifact viewer -
- -
- - - -
- - `; - this.toolbarElement.innerHTML = html; - this.folderSpan = this.toolbarElement.querySelector("#folderSpan"); - this.tweakButton = this.toolbarElement.querySelector("#tweak"); - this.restartButton = this.toolbarElement.querySelector("#restart"); - this.downloadButton = this.toolbarElement.querySelector("#download"); - this.downloadPopup = this.toolbarElement.querySelector("#downloadPopup"); - if (this.options.tweakButton) { - this.showTweakButton(); - } - if (this.options.restartButton) { - this.showRestartButton(); - } - if (this.options.downloadButton) { - this.showDownloadButton(); - } - (_a = this.tweakButton) == null ? void 0 : _a.addEventListener("click", () => { - this.options.textarea.style.display = this.options.textarea.style.display === "none" ? "" : "none"; - }); - (_b = this.restartButton) == null ? void 0 : _b.addEventListener("click", () => { - window.location.reload(); - }); - (_c = this.downloadButton) == null ? void 0 : _c.addEventListener("click", (e) => { - const rect = this.downloadButton.getBoundingClientRect(); - const viewportWidth = window.innerWidth; - const buttonCenter = rect.left + rect.width / 2; - const isLeftOfCenter = buttonCenter < viewportWidth / 2; - if (isLeftOfCenter) { - this.downloadPopup.style.left = `${rect.left + window.scrollX}px`; - this.downloadPopup.style.right = ""; - } else { - this.downloadPopup.offsetWidth; - this.downloadPopup.style.right = `${viewportWidth - rect.right - window.scrollX}px`; - this.downloadPopup.style.left = ""; - } - this.downloadPopup.style.top = `${rect.bottom + window.scrollY + 4}px`; - this.downloadPopup.style.display = "block"; - const hidePopup = (evt) => { - if (!this.downloadPopup.contains(evt.target) && evt.target !== this.downloadButton) { - this.downloadPopup.style.display = "none"; - document.removeEventListener("mousedown", hidePopup); + this.props = { + mode: this.mode, + restartClick: () => window.location.reload(), + tweakClick: () => { + this.options.textarea.style.display = this.options.textarea.style.display === "none" ? "" : "none"; + }, + downloadClick: () => { + const { downloadPopup, downloadButton } = this; + const rect = downloadButton.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const buttonCenter = rect.left + rect.width / 2; + const isLeftOfCenter = buttonCenter < viewportWidth / 2; + if (isLeftOfCenter) { + downloadPopup.style.left = `${rect.left + window.scrollX}px`; + downloadPopup.style.right = ""; + } else { + downloadPopup.offsetWidth; + downloadPopup.style.right = `${viewportWidth - rect.right - window.scrollX}px`; + downloadPopup.style.left = ""; } - }; - setTimeout(() => document.addEventListener("mousedown", hidePopup), 0); - }); - (_d = this.downloadPopup.querySelector("#download-md")) == null ? void 0 : _d.addEventListener("click", () => { - this.downloadPopup.style.display = "none"; - const textarea = this.options.textarea; - if (!textarea) - return; - const content = textarea.value; - const filename = `${filenameWithoutPathOrExtension(this.filename)}.idoc.md`; - this.triggerDownload(content, filename, "text/markdown"); - }); - (_e = this.downloadPopup.querySelector("#download-html")) == null ? void 0 : _e.addEventListener("click", () => { - this.downloadPopup.style.display = "none"; - const textarea = this.options.textarea; - if (!textarea) - return; - const filename = `${filenameWithoutPathOrExtension(this.filename)}.idoc.html`; - const html2 = this.htmlWrapper(); - this.triggerDownload(html2, filename, "text/html"); - }); + downloadPopup.style.top = `${rect.bottom + window.scrollY + 4}px`; + downloadPopup.style.display = "block"; + const hidePopup = (evt) => { + if (!downloadPopup.contains(evt.target) && evt.target !== downloadButton) { + downloadPopup.style.display = "none"; + document.removeEventListener("mousedown", hidePopup); + } + }; + setTimeout(() => document.addEventListener("mousedown", hidePopup), 0); + }, + restartDisplay: this.options.restartButton ? "" : "none", + tweakDisplay: this.options.tweakButton ? "" : "none", + downloadDisplay: this.options.downloadButton ? "" : "none", + downloadSource: () => { + this.downloadPopup.style.display = "none"; + const textarea = this.options.textarea; + if (!textarea) + return; + const content = textarea.value; + const extension = this.mode === "json" ? ".idoc.json" : ".idoc.md"; + const mimeType = this.mode === "json" ? "application/json" : "text/markdown"; + const filename = `${filenameWithoutPathOrExtension(this.filename)}${extension}`; + this.triggerDownload(content, filename, mimeType); + }, + downloadHtml: () => { + this.downloadPopup.style.display = "none"; + const textarea = this.options.textarea; + if (!textarea) + return; + const html = this.htmlWrapper(); + const filename = `${filenameWithoutPathOrExtension(this.filename)}.idoc.html`; + this.triggerDownload(html, filename, "text/html"); + } + }; + this.render(); } htmlWrapper() { if (this.mode === "markdown") { @@ -3254,6 +3400,15 @@ ${htmlJsonJs} return index$1.htmlJsonWrapper(this.filename, this.options.textarea.value); } } + addChildren(children) { + this.props.children = children; + this.render(); + } + render() { + mount(ToolbarElement(this.props), this.toolbarElement); + this.downloadButton = this.toolbarElement.querySelector("#download"); + this.downloadPopup = this.toolbarElement.querySelector("#downloadPopup"); + } // Helper method to trigger a download triggerDownload(content, filename, mimeType) { const blob = new Blob([content], { type: mimeType }); @@ -3269,13 +3424,16 @@ ${htmlJsonJs} }, 0); } showTweakButton() { - this.tweakButton.style.display = ""; + this.props.tweakDisplay = ""; + this.render(); } showRestartButton() { - this.restartButton.style.display = ""; + this.props.restartDisplay = ""; + this.render(); } showDownloadButton() { - this.downloadButton.style.display = ""; + this.props.downloadDisplay = ""; + this.render(); } manageTextareaVisibilityForAgents() { const { textarea } = this.options; diff --git a/docs/dist/v1/chartifact.toolbar.umd.js b/docs/dist/v1/chartifact.toolbar.umd.js index 6a2d243f..fd4aafae 100644 --- a/docs/dist/v1/chartifact.toolbar.umd.js +++ b/docs/dist/v1/chartifact.toolbar.umd.js @@ -172,109 +172,224 @@ ${htmlJsonJs} htmlMarkdownWrapper, htmlJsonWrapper }; + const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; + function decamelize(str, separator = "-") { + return str.replace(/([a-z\d])([A-Z])/g, "$1" + separator + "$2").replace(/([A-Z]+)([A-Z][a-z\d]+)/g, "$1" + separator + "$2").toLowerCase(); + } + function createElement(tag, attrs, ...children) { + if (typeof tag === "function") { + const fn = tag; + let props = attrs; + if (props === null || props === void 0) { + props = { children }; + } else { + props.children = children; + } + return fn(props); + } else { + const ns = tag === "svg" ? SVG_NAMESPACE : null; + const el = ns ? document.createElementNS(ns, tag) : document.createElement(tag); + const map = attrs; + let ref; + for (let name in map) { + if (name && map.hasOwnProperty(name)) { + let value = map[name]; + if (name === "className" && value !== void 0) { + setAttribute(el, ns, "class", value.toString()); + } else if (name === "disabled" && !value) ; + else if (value === null || value === void 0) { + continue; + } else if (value === true) { + setAttribute(el, ns, name, name); + } else if (typeof value === "function") { + if (name === "ref") { + ref = value; + } else { + el[name.toLowerCase()] = value; + } + } else if (typeof value === "object") { + setAttribute(el, ns, name, flatten(value)); + } else { + setAttribute(el, ns, name, value.toString()); + } + } + } + if (children && children.length > 0) { + appendChildren(el, children); + } + if (ref) { + ref(el); + } + return el; + } + } + function setAttribute(el, ns, name, value) { + if (ns) { + el.setAttributeNS(null, name, value); + } else { + el.setAttribute(name, value); + } + } + function flatten(o) { + const arr = []; + for (let prop in o) + arr.push(`${decamelize(prop, "-")}:${o[prop]}`); + return arr.join(";"); + } + function isInsideForeignObject(element) { + let current = element; + while (current) { + if (current.tagName.toLowerCase() === "foreignobject") { + return true; + } + current = current.parentElement; + } + return false; + } + function recreateWithSvgNamespace(element) { + const svgElement = document.createElementNS(SVG_NAMESPACE, element.tagName.toLowerCase()); + for (let i = 0; i < element.attributes.length; i++) { + const attr = element.attributes[i]; + svgElement.setAttributeNS(null, attr.name, attr.value); + } + const eventProperties = [ + "onclick", + "onmousedown", + "onmouseup", + "onmouseover", + "onmouseout", + "onmousemove", + "onkeydown", + "onkeyup", + "onkeypress", + "onfocus", + "onblur" + ]; + for (const prop of eventProperties) { + if (element[prop]) { + svgElement[prop] = element[prop]; + } + } + for (let i = 0; i < element.childNodes.length; i++) { + const child = element.childNodes[i]; + if (child.nodeType === Node.ELEMENT_NODE) { + svgElement.appendChild(recreateWithSvgNamespace(child)); + } else { + svgElement.appendChild(child.cloneNode(true)); + } + } + return svgElement; + } + function addChild(parentElement, child) { + if (child === null || child === void 0 || typeof child === "boolean") { + return; + } else if (Array.isArray(child)) { + appendChildren(parentElement, child); + } else if (isElement(child)) { + const childEl = child; + if (parentElement.namespaceURI === SVG_NAMESPACE && childEl.namespaceURI !== SVG_NAMESPACE && childEl.tagName.toLowerCase() !== "foreignobject" && !isInsideForeignObject(parentElement)) { + const recreated = recreateWithSvgNamespace(childEl); + parentElement.appendChild(recreated); + } else { + parentElement.appendChild(childEl); + } + } else { + parentElement.appendChild(document.createTextNode(child.toString())); + } + } + function appendChildren(parentElement, children) { + children.forEach((child) => addChild(parentElement, child)); + } + function isElement(el) { + return !!el.nodeType; + } + function mount(element, container) { + container.innerHTML = ""; + if (element) { + addChild(container, element); + } + } + const ToolbarElement = (props) => { + const { mode, restartClick, tweakClick, downloadClick, restartDisplay, tweakDisplay, downloadDisplay, downloadSource, downloadHtml, children } = props; + const { home, target } = window.location.hostname === "localhost" ? { home: "/", target: "_self" } : { home: "https://microsoft.github.io/", target: "_blank" }; + const displayMode = mode === "json" ? "json" : "markdown"; + return /* @__PURE__ */ createElement("div", { className: "toolbar-group", style: { backgroundColor: "inherit" } }, /* @__PURE__ */ createElement("div", { className: "toolbar-item" }, /* @__PURE__ */ createElement("a", { href: `${home}chartifact/`, target }, "Chartifact"), " viewer"), /* @__PURE__ */ createElement("div", { className: "toolbar-item", style: { display: children ? "" : "none" } }, children), /* @__PURE__ */ createElement("div", { className: "toolbar-item" }, /* @__PURE__ */ createElement("button", { type: "button", id: "restart", style: { display: restartDisplay }, onClick: restartClick }, "start over"), /* @__PURE__ */ createElement("button", { type: "button", id: "tweak", style: { display: tweakDisplay }, onClick: tweakClick }, "view source"), /* @__PURE__ */ createElement("button", { type: "button", id: "download", style: { display: downloadDisplay }, onClick: downloadClick }, "download")), /* @__PURE__ */ createElement("div", { id: "downloadPopup", style: { + position: "absolute", + display: "none", + padding: "12px 16px", + zIndex: 1, + backgroundColor: "inherit" + } }, /* @__PURE__ */ createElement("div", { style: { marginBottom: "8px" } }, "Download as:"), /* @__PURE__ */ createElement("ul", null, /* @__PURE__ */ createElement("li", null, "Source (just the ", displayMode, " content)", /* @__PURE__ */ createElement("br", null), /* @__PURE__ */ createElement("button", { type: "button", id: "download-md", style: { marginRight: "8px" }, onClick: downloadSource }, "Source")), /* @__PURE__ */ createElement("li", null, "HTML wrapper (content plus a shareable viewer)", /* @__PURE__ */ createElement("br", null), /* @__PURE__ */ createElement("button", { type: "button", id: "download-html", onClick: downloadHtml }, "HTML wrapper"))))); + }; class Toolbar { constructor(toolbarElementOrSelector, options = {}) { __publicField(this, "toolbarElement"); - __publicField(this, "folderSpan"); - __publicField(this, "tweakButton"); - __publicField(this, "restartButton"); __publicField(this, "downloadButton"); + __publicField(this, "downloadPopup"); __publicField(this, "mode"); __publicField(this, "filename"); - __publicField(this, "downloadPopup"); - var _a, _b, _c, _d, _e; + __publicField(this, "props"); this.options = options; this.filename = options.filename || "sample"; - this.mode = options.mode || "markdown"; + const allowedModes = ["markdown", "json"]; + this.mode = allowedModes.includes(options.mode) ? options.mode : "markdown"; this.toolbarElement = typeof toolbarElementOrSelector === "string" ? document.querySelector(toolbarElementOrSelector) : toolbarElementOrSelector; if (!this.toolbarElement) { throw new Error("Toolbar element not found"); } - const { home, target } = window.location.hostname === "localhost" ? { home: "/", target: "_self" } : { home: "https://microsoft.github.io/", target: "_blank" }; - const html = ` -
- Chartifact viewer -
- -
- - - -
- - `; - this.toolbarElement.innerHTML = html; - this.folderSpan = this.toolbarElement.querySelector("#folderSpan"); - this.tweakButton = this.toolbarElement.querySelector("#tweak"); - this.restartButton = this.toolbarElement.querySelector("#restart"); - this.downloadButton = this.toolbarElement.querySelector("#download"); - this.downloadPopup = this.toolbarElement.querySelector("#downloadPopup"); - if (this.options.tweakButton) { - this.showTweakButton(); - } - if (this.options.restartButton) { - this.showRestartButton(); - } - if (this.options.downloadButton) { - this.showDownloadButton(); - } - (_a = this.tweakButton) == null ? void 0 : _a.addEventListener("click", () => { - this.options.textarea.style.display = this.options.textarea.style.display === "none" ? "" : "none"; - }); - (_b = this.restartButton) == null ? void 0 : _b.addEventListener("click", () => { - window.location.reload(); - }); - (_c = this.downloadButton) == null ? void 0 : _c.addEventListener("click", (e) => { - const rect = this.downloadButton.getBoundingClientRect(); - const viewportWidth = window.innerWidth; - const buttonCenter = rect.left + rect.width / 2; - const isLeftOfCenter = buttonCenter < viewportWidth / 2; - if (isLeftOfCenter) { - this.downloadPopup.style.left = `${rect.left + window.scrollX}px`; - this.downloadPopup.style.right = ""; - } else { - this.downloadPopup.offsetWidth; - this.downloadPopup.style.right = `${viewportWidth - rect.right - window.scrollX}px`; - this.downloadPopup.style.left = ""; - } - this.downloadPopup.style.top = `${rect.bottom + window.scrollY + 4}px`; - this.downloadPopup.style.display = "block"; - const hidePopup = (evt) => { - if (!this.downloadPopup.contains(evt.target) && evt.target !== this.downloadButton) { - this.downloadPopup.style.display = "none"; - document.removeEventListener("mousedown", hidePopup); + this.props = { + mode: this.mode, + restartClick: () => window.location.reload(), + tweakClick: () => { + this.options.textarea.style.display = this.options.textarea.style.display === "none" ? "" : "none"; + }, + downloadClick: () => { + const { downloadPopup, downloadButton } = this; + const rect = downloadButton.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const buttonCenter = rect.left + rect.width / 2; + const isLeftOfCenter = buttonCenter < viewportWidth / 2; + if (isLeftOfCenter) { + downloadPopup.style.left = `${rect.left + window.scrollX}px`; + downloadPopup.style.right = ""; + } else { + downloadPopup.offsetWidth; + downloadPopup.style.right = `${viewportWidth - rect.right - window.scrollX}px`; + downloadPopup.style.left = ""; } - }; - setTimeout(() => document.addEventListener("mousedown", hidePopup), 0); - }); - (_d = this.downloadPopup.querySelector("#download-md")) == null ? void 0 : _d.addEventListener("click", () => { - this.downloadPopup.style.display = "none"; - const textarea = this.options.textarea; - if (!textarea) return; - const content = textarea.value; - const filename = `${filenameWithoutPathOrExtension(this.filename)}.idoc.md`; - this.triggerDownload(content, filename, "text/markdown"); - }); - (_e = this.downloadPopup.querySelector("#download-html")) == null ? void 0 : _e.addEventListener("click", () => { - this.downloadPopup.style.display = "none"; - const textarea = this.options.textarea; - if (!textarea) return; - const filename = `${filenameWithoutPathOrExtension(this.filename)}.idoc.html`; - const html2 = this.htmlWrapper(); - this.triggerDownload(html2, filename, "text/html"); - }); + downloadPopup.style.top = `${rect.bottom + window.scrollY + 4}px`; + downloadPopup.style.display = "block"; + const hidePopup = (evt) => { + if (!downloadPopup.contains(evt.target) && evt.target !== downloadButton) { + downloadPopup.style.display = "none"; + document.removeEventListener("mousedown", hidePopup); + } + }; + setTimeout(() => document.addEventListener("mousedown", hidePopup), 0); + }, + restartDisplay: this.options.restartButton ? "" : "none", + tweakDisplay: this.options.tweakButton ? "" : "none", + downloadDisplay: this.options.downloadButton ? "" : "none", + downloadSource: () => { + this.downloadPopup.style.display = "none"; + const textarea = this.options.textarea; + if (!textarea) return; + const content = textarea.value; + const extension = this.mode === "json" ? ".idoc.json" : ".idoc.md"; + const mimeType = this.mode === "json" ? "application/json" : "text/markdown"; + const filename = `${filenameWithoutPathOrExtension(this.filename)}${extension}`; + this.triggerDownload(content, filename, mimeType); + }, + downloadHtml: () => { + this.downloadPopup.style.display = "none"; + const textarea = this.options.textarea; + if (!textarea) return; + const html = this.htmlWrapper(); + const filename = `${filenameWithoutPathOrExtension(this.filename)}.idoc.html`; + this.triggerDownload(html, filename, "text/html"); + } + }; + this.render(); } htmlWrapper() { if (this.mode === "markdown") { @@ -283,6 +398,15 @@ ${htmlJsonJs} return index$1.htmlJsonWrapper(this.filename, this.options.textarea.value); } } + addChildren(children) { + this.props.children = children; + this.render(); + } + render() { + mount(ToolbarElement(this.props), this.toolbarElement); + this.downloadButton = this.toolbarElement.querySelector("#download"); + this.downloadPopup = this.toolbarElement.querySelector("#downloadPopup"); + } // Helper method to trigger a download triggerDownload(content, filename, mimeType) { const blob = new Blob([content], { type: mimeType }); @@ -298,13 +422,16 @@ ${htmlJsonJs} }, 0); } showTweakButton() { - this.tweakButton.style.display = ""; + this.props.tweakDisplay = ""; + this.render(); } showRestartButton() { - this.restartButton.style.display = ""; + this.props.restartDisplay = ""; + this.render(); } showDownloadButton() { - this.downloadButton.style.display = ""; + this.props.downloadDisplay = ""; + this.render(); } manageTextareaVisibilityForAgents() { const { textarea } = this.options; diff --git a/package-lock.json b/package-lock.json index d8e19adc..94dc39b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "rimraf": "^6.0.1", "rollup-plugin-dts": "^6.2.1", "ts-json-schema-generator": "^2.4.0", + "tsx-create-element": "^2.3.0", "typescript": "^5.8.3", "vega-typings": "^2.0.1", "vite": "^6.3.6" @@ -13761,6 +13762,16 @@ "node": ">=0.6.x" } }, + "node_modules/tsx-create-element": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tsx-create-element/-/tsx-create-element-2.3.0.tgz", + "integrity": "sha512-BMRK11fA8dQSqufL7u5HpqVSIzHBrXJldT3VNKJ1677fJ9NwEWEL7vRfA1ue6rtRnkKA99dwiLk1TR6Ox+erJw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", diff --git a/package.json b/package.json index 4edaabb8..60b438c2 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "rimraf": "^6.0.1", "rollup-plugin-dts": "^6.2.1", "ts-json-schema-generator": "^2.4.0", + "tsx-create-element": "^2.3.0", "typescript": "^5.8.3", "vega-typings": "^2.0.1", "vite": "^6.3.6" diff --git a/packages/host/src/folder.ts b/packages/host/src/folder.tsx similarity index 55% rename from packages/host/src/folder.ts rename to packages/host/src/folder.tsx index 0fb58b5a..4cc63961 100644 --- a/packages/host/src/folder.ts +++ b/packages/host/src/folder.tsx @@ -3,9 +3,48 @@ * Licensed under the MIT License. */ -import { Folder } from "@microsoft/chartifact-schema-folder"; +import { Folder, DocRef } from "@microsoft/chartifact-schema-folder"; import { Listener } from "./listener.js"; import { loadViaUrl } from "./url.js"; +import { createElement } from 'tsx-create-element'; + +interface FolderDisplayProps { + title: string; + docIndex: number; + docs: DocRef[]; + prevClick: () => void; + nextClick: () => void; + pageSelectChange: (index: number) => void; +} + +const FolderDisplay = (props: FolderDisplayProps) => { + const { title, docIndex, docs, prevClick, nextClick } = props; + return ( + + {title} +
+ (document {docIndex + 1} of {docs.length}) +
+ + + +
+
+ ); +} export function loadFolder(folderUrl: string, folder: Folder, host: Listener) { if (!folder || !folder.docs) { @@ -34,35 +73,6 @@ export function loadFolder(folderUrl: string, folder: Folder, host: Listener) { let docIndex = 0; - const { folderSpan } = host.toolbar; - folderSpan.style.display = ''; - - // Create Previous and Next buttons - const prevBtn = document.createElement('button'); - prevBtn.textContent = 'Previous'; - prevBtn.disabled = docIndex === 0; - - const nextBtn = document.createElement('button'); - nextBtn.textContent = 'Next'; - nextBtn.disabled = docIndex === folder.docs.length - 1; - - // Create dropdown for page selection - const pageSelect = document.createElement('select'); - for (let i = 0; i < folder.docs.length; i++) { - const option = document.createElement('option'); - option.value = (i + 1).toString(); - option.textContent = folder.docs[i].title ? folder.docs[i].title : `Page ${i + 1}`; - pageSelect.appendChild(option); - } - pageSelect.value = (docIndex + 1).toString(); - - // --- Wrap controls in a div --- - const navDiv = document.createElement('div'); - navDiv.style.display = 'inline-block'; - navDiv.appendChild(prevBtn); - navDiv.appendChild(pageSelect); - navDiv.appendChild(nextBtn); - function getHashParam(key: string): string | undefined { const params = new URLSearchParams(window.location.hash.slice(1)); return params.get(key) ?? undefined; @@ -74,64 +84,44 @@ export function loadFolder(folderUrl: string, folder: Folder, host: Listener) { window.location.hash = params.toString(); } - function updateFolderTitle() { - // Clear previous content - folderSpan.innerHTML = ''; - - // Folder label - const label = document.createElement('span'); - label.textContent = `${folder.title} `; - - // Document count in its own inline-block div - const docCountDiv = document.createElement('div'); - docCountDiv.style.display = 'inline-block'; - docCountDiv.style.marginRight = '0.5em'; - docCountDiv.textContent = `(document ${docIndex + 1} of ${folder.docs.length}) `; - - folderSpan.appendChild(label); - folderSpan.appendChild(docCountDiv); - folderSpan.appendChild(navDiv); - } - - // Initial folder title - updateFolderTitle(); + const props: FolderDisplayProps = { + title: folder.title, + docIndex, + docs: folder.docs, + prevClick: () => { + if (docIndex > 0) { + updatePage(docIndex - 1, true); + } + }, + nextClick: () => { + if (docIndex < folder.docs.length - 1) { + updatePage(docIndex + 1, true); + } + }, + pageSelectChange: (index) => { + if (index >= 0 && index <= folder.docs.length - 1) { + updatePage(index, true); + } + } + }; function updatePage(newDocIndex: number, setHash: boolean = false) { docIndex = newDocIndex; if (setHash) { setHashParam('page', docIndex + 1); } - prevBtn.disabled = docIndex === 0; - nextBtn.disabled = docIndex === folder.docs.length - 1; - pageSelect.value = (docIndex + 1).toString(); - // Use the function to update the folder title - updateFolderTitle(); + host.toolbar.addChildren(); + + const pageSelect = document.querySelector('#pageSelect') as HTMLSelectElement; + if (pageSelect) { + pageSelect.value = (docIndex + 1).toString(); + } const title = folder.docs[docIndex].title || `Page ${docIndex + 1}`; resolveUrl(title, folderUrl, folder.docs[docIndex].href, host); } - pageSelect.onchange = () => { - const selectedPage = parseInt(pageSelect.value, 10); - if (selectedPage >= 1 && selectedPage <= folder.docs.length) { - updatePage(selectedPage - 1, true); - } - }; - - // Button click handlers - prevBtn.onclick = () => { - if (docIndex > 0) { - updatePage(docIndex - 1, true); - } - }; - - nextBtn.onclick = () => { - if (docIndex < folder.docs.length - 1) { - updatePage(docIndex + 1, true); - } - }; - // Set initial hash and handle hash navigation function goToPageFromHash() { const pageStr = getHashParam('page'); @@ -153,9 +143,6 @@ export function loadFolder(folderUrl: string, folder: Folder, host: Listener) { } goToPageFromHash(); - // Add buttons and dropdown to the toolbar - folderSpan.appendChild(navDiv); - //resolveUrl(folderUrl, folder.docUrls[docIndex], host); } diff --git a/packages/host/tsconfig.json b/packages/host/tsconfig.json index 4b1e9828..cdb23210 100644 --- a/packages/host/tsconfig.json +++ b/packages/host/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "../tsconfig.base.json", "compilerOptions": { + "jsx": "react", + "jsxFactory": "createElement", "outDir": "dist/esnext" }, "include": [ diff --git a/packages/toolbar/chartifact-toolbar.css b/packages/toolbar/chartifact-toolbar.css index 2e4e8848..402c025c 100644 --- a/packages/toolbar/chartifact-toolbar.css +++ b/packages/toolbar/chartifact-toolbar.css @@ -58,17 +58,17 @@ } /* Default: stack toolbar sections vertically */ -.chartifact-toolbar > div { +.chartifact-toolbar .toolbar-item { margin: 6px 0; } /* Wide screens: layout horizontally */ @media (min-width: 1000px) { - .chartifact-toolbar { + .chartifact-toolbar .toolbar-group { display: flex; justify-content: space-between; } - .chartifact-toolbar > div { + .chartifact-toolbar .toolbar-item { display: inline-block; margin: 0; } diff --git a/packages/toolbar/src/toolbar.ts b/packages/toolbar/src/toolbar.ts deleted file mode 100644 index 29dbc627..00000000 --- a/packages/toolbar/src/toolbar.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** -* Copyright (c) Microsoft Corporation. -* Licensed under the MIT License. -*/ - -import * as hw from 'html-wrapper'; - -export interface ToolbarOptions { - tweakButton?: boolean; - downloadButton?: boolean; - restartButton?: boolean; - textarea?: HTMLTextAreaElement; - mode?: 'markdown' | 'json'; - filename?: string; -} - -export class Toolbar { - public toolbarElement: HTMLElement; - public folderSpan: HTMLElement; - public tweakButton: HTMLButtonElement; - public restartButton: HTMLButtonElement; - public downloadButton: HTMLButtonElement; - public mode: 'markdown' | 'json'; - public filename: string; - private downloadPopup: HTMLDivElement; - - constructor(toolbarElementOrSelector: HTMLElement | string, public options: ToolbarOptions = {}) { - this.filename = options.filename || 'sample'; - this.mode = options.mode || 'markdown'; - this.toolbarElement = typeof toolbarElementOrSelector === 'string' ? document.querySelector(toolbarElementOrSelector) : toolbarElementOrSelector; - - if (!this.toolbarElement) { - throw new Error('Toolbar element not found'); - } - - const { home, target } = (window.location.hostname === 'localhost') - ? { home: '/', target: '_self' } - : { home: 'https://microsoft.github.io/', target: '_blank' }; - - const html = ` -
- Chartifact viewer -
- -
- - - -
- - `; - - this.toolbarElement.innerHTML = html; - - this.folderSpan = this.toolbarElement.querySelector('#folderSpan') as HTMLElement; - this.tweakButton = this.toolbarElement.querySelector('#tweak') as HTMLButtonElement; - this.restartButton = this.toolbarElement.querySelector('#restart') as HTMLButtonElement; - this.downloadButton = this.toolbarElement.querySelector('#download') as HTMLButtonElement; - this.downloadPopup = this.toolbarElement.querySelector('#downloadPopup') as HTMLDivElement; - - if (this.options.tweakButton) { - this.showTweakButton(); - } - - if (this.options.restartButton) { - this.showRestartButton(); - } - - if (this.options.downloadButton) { - this.showDownloadButton(); - } - - this.tweakButton?.addEventListener('click', () => { - this.options.textarea.style.display = this.options.textarea.style.display === 'none' ? '' : 'none'; - }); - - this.restartButton?.addEventListener('click', () => { - window.location.reload(); - }); - - this.downloadButton?.addEventListener('click', (e) => { - // Position popup near the button - const rect = this.downloadButton.getBoundingClientRect(); - const viewportWidth = window.innerWidth; - const buttonCenter = rect.left + rect.width / 2; - const isLeftOfCenter = buttonCenter < viewportWidth / 2; - - if (isLeftOfCenter) { - // Align popup to the left of the button - this.downloadPopup.style.left = `${rect.left + window.scrollX}px`; - this.downloadPopup.style.right = ''; // Clear right alignment - } else { - // Align popup to the right of the button - const popupWidth = this.downloadPopup.offsetWidth; - this.downloadPopup.style.right = `${viewportWidth - rect.right - window.scrollX}px`; - this.downloadPopup.style.left = ''; // Clear left alignment - } - - this.downloadPopup.style.top = `${rect.bottom + window.scrollY + 4}px`; - this.downloadPopup.style.display = 'block'; - - // Hide popup on outside click - const hidePopup = (evt: MouseEvent) => { - if (!this.downloadPopup.contains(evt.target as Node) && evt.target !== this.downloadButton) { - this.downloadPopup.style.display = 'none'; - document.removeEventListener('mousedown', hidePopup); - } - }; - setTimeout(() => document.addEventListener('mousedown', hidePopup), 0); - }); - - // Download as markdown - this.downloadPopup.querySelector('#download-md')?.addEventListener('click', () => { - this.downloadPopup.style.display = 'none'; - const textarea = this.options.textarea; - if (!textarea) return; - const content = textarea.value; - const filename = `${filenameWithoutPathOrExtension(this.filename)}.idoc.md`; - this.triggerDownload(content, filename, 'text/markdown'); - }); - - // Download as HTML wrapper - this.downloadPopup.querySelector('#download-html')?.addEventListener('click', () => { - this.downloadPopup.style.display = 'none'; - const textarea = this.options.textarea; - if (!textarea) return; - const filename = `${filenameWithoutPathOrExtension(this.filename)}.idoc.html`; - const html = this.htmlWrapper(); - this.triggerDownload(html, filename, 'text/html'); - }); - } - - public htmlWrapper() { - if (this.mode === 'markdown') { - return hw.default.htmlMarkdownWrapper(this.filename, this.options.textarea.value); - } else if (this.mode === 'json') { - return hw.default.htmlJsonWrapper(this.filename, this.options.textarea.value); - } - } - - // Helper method to trigger a download - private triggerDownload(content: string, filename: string, mimeType: string) { - const blob = new Blob([content], { type: mimeType }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - setTimeout(() => { - document.body.removeChild(a); - URL.revokeObjectURL(url); - }, 0); - } - - showTweakButton() { - this.tweakButton.style.display = ''; - } - - showRestartButton() { - this.restartButton.style.display = ''; - } - - showDownloadButton() { - this.downloadButton.style.display = ''; - } - - manageTextareaVisibilityForAgents() { - const { textarea } = this.options; - - if (!textarea) { - throw new Error('Textarea element not found'); - } - - // Bot-friendly content hiding strategy: - // 1. Bots that don't execute JS will see the textarea content in full - // 2. Bots that execute JS but have short timeouts will see content for 300ms - // 3. Human users see no visual flash due to flex:0 + removed padding/border - textarea.style.flex = '0'; // Collapse to zero width, no visual space - textarea.style.padding = '0'; // Remove default padding that creates pixel shift - textarea.style.border = '0'; // Remove default border that creates pixel shift - setTimeout(() => { - // After 300ms (longer than most bot JS timeouts), restore defaults and hide - textarea.style.flex = ''; // Restore original flex value from CSS - textarea.style.padding = ''; // Restore original padding from CSS - textarea.style.border = ''; // Restore original border from CSS - textarea.style.display = 'none'; // Fully hide from users (but they can unhide to edit) - }, 300); - - } -} - -function filenameWithoutPathOrExtension(filename: string) { - // Remove everything before the last slash or backslash - const base = filename.split(/[\\/]/).pop() || filename; - // If .idoc appears, remove it and everything after - const idocIdx = base.indexOf('.idoc'); - if (idocIdx !== -1) { - return base.substring(0, idocIdx); - } - // Otherwise, remove the last extension if present - const lastDot = base.lastIndexOf('.'); - if (lastDot > 0) { - return base.substring(0, lastDot); - } - return base; -} - -// --- TESTS for filenameWithoutPathOrExtension --- - -// const filenameTests: [string, string][] = [ -// // [input, expected] -// ["foo.md", "foo"], -// ["foo.idoc.md", "foo"], -// ["foo.idoc.json", "foo"], -// ["foo", "foo"], -// ["foo.bar.baz.md", "foo.bar.baz"], -// ["C:\\folder\\foo.md", "foo"], -// ["/home/user/foo.md", "foo"], -// ["folder/foo.idoc.md", "foo"], -// ["folder\\foo.idoc.json", "foo"], -// ["folder.with.dots/foo.bar.baz.md", "foo.bar.baz"], -// ["folder.with.dots\\foo.bar.baz.md", "foo.bar.baz"], -// ["", ""], -// ]; - -// filenameTests.forEach(([input, expected], i) => { -// const result = filenameWithoutPathOrExtension(input); -// const pass = result === expected; -// // eslint-disable-next-line no-console -// console.log( -// `${pass ? '✅' : '❌'} Test ${i + 1}: ${pass ? 'PASS' : `FAIL\n Input: ${input}\n Got: ${result}\n Expected: ${expected}`}` -// ); -// }); - diff --git a/packages/toolbar/src/toolbar.tsx b/packages/toolbar/src/toolbar.tsx new file mode 100644 index 00000000..2ff4c9b5 --- /dev/null +++ b/packages/toolbar/src/toolbar.tsx @@ -0,0 +1,283 @@ +/** +* Copyright (c) Microsoft Corporation. +* Licensed under the MIT License. +*/ + +import * as hw from 'html-wrapper'; +import { createElement, mount } from 'tsx-create-element'; + +export interface ToolbarOptions { + tweakButton?: boolean; + downloadButton?: boolean; + restartButton?: boolean; + textarea?: HTMLTextAreaElement; + /** + * Mode to display content, allowed values: 'markdown' | 'json'. + * Only trusted values should be supplied. + * If an untrusted value is provided, it will be ignored and replaced with 'markdown'. + */ + mode?: 'markdown' | 'json'; + filename?: string; +} + +interface ToolbarProps { + mode: 'markdown' | 'json'; + restartClick: () => void; + tweakClick: () => void; + downloadClick: () => void; + restartDisplay: 'none' | ''; + tweakDisplay: 'none' | ''; + downloadDisplay: 'none' | ''; + downloadSource: () => void; + downloadHtml: () => void; + children?: JSX.Element; +} + +const ToolbarElement = (props: ToolbarProps) => { + + const { mode, restartClick, tweakClick, downloadClick, restartDisplay, tweakDisplay, downloadDisplay, downloadSource, downloadHtml, children } = props; + + const { home, target } = (window.location.hostname === 'localhost') + ? { home: '/', target: '_self' } + : { home: 'https://microsoft.github.io/', target: '_blank' }; + + const displayMode = mode === 'json' ? 'json' : 'markdown'; + + return ( +
+
+ Chartifact viewer +
+
+ {children} +
+
+ + + +
+
+
Download as:
+
    +
  • + Source (just the {displayMode} content)
    + +
  • +
  • + HTML wrapper (content plus a shareable viewer)
    + +
  • +
+
+
+ ); +} + +export class Toolbar { + public toolbarElement: HTMLElement; + public downloadButton: HTMLButtonElement; + public downloadPopup: HTMLDivElement; + public mode: 'markdown' | 'json'; + public filename: string; + public props: ToolbarProps; + + constructor(toolbarElementOrSelector: HTMLElement | string, public options: ToolbarOptions = {}) { + this.filename = options.filename || 'sample'; + // Runtime check to restrict mode to allowed values only + const allowedModes = ['markdown', 'json']; + this.mode = allowedModes.includes(options.mode as string) ? options.mode as 'markdown' | 'json' : 'markdown'; + + this.toolbarElement = typeof toolbarElementOrSelector === 'string' ? document.querySelector(toolbarElementOrSelector) : toolbarElementOrSelector; + + if (!this.toolbarElement) { + throw new Error('Toolbar element not found'); + } + + this.props = { + mode: this.mode, + restartClick: () => window.location.reload(), + tweakClick: () => { + this.options.textarea.style.display = this.options.textarea.style.display === 'none' ? '' : 'none'; + }, + downloadClick: () => { + const { downloadPopup, downloadButton } = this; + + // Position popup near the button + const rect = downloadButton.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const buttonCenter = rect.left + rect.width / 2; + const isLeftOfCenter = buttonCenter < viewportWidth / 2; + + if (isLeftOfCenter) { + // Align popup to the left of the button + downloadPopup.style.left = `${rect.left + window.scrollX}px`; + downloadPopup.style.right = ''; // Clear right alignment + } else { + // Align popup to the right of the button + const popupWidth = downloadPopup.offsetWidth; + downloadPopup.style.right = `${viewportWidth - rect.right - window.scrollX}px`; + downloadPopup.style.left = ''; // Clear left alignment + } + + downloadPopup.style.top = `${rect.bottom + window.scrollY + 4}px`; + downloadPopup.style.display = 'block'; + + // Hide popup on outside click + const hidePopup = (evt: MouseEvent) => { + if (!downloadPopup.contains(evt.target as Node) && evt.target !== downloadButton) { + downloadPopup.style.display = 'none'; + document.removeEventListener('mousedown', hidePopup); + } + }; + setTimeout(() => document.addEventListener('mousedown', hidePopup), 0); + }, + restartDisplay: this.options.restartButton ? '' : 'none', + tweakDisplay: this.options.tweakButton ? '' : 'none', + downloadDisplay: this.options.downloadButton ? '' : 'none', + downloadSource: () => { + this.downloadPopup.style.display = 'none'; + const textarea = this.options.textarea; + if (!textarea) return; + const content = textarea.value; + const extension = this.mode === 'json' ? '.idoc.json' : '.idoc.md'; + const mimeType = this.mode === 'json' ? 'application/json' : 'text/markdown'; + const filename = `${filenameWithoutPathOrExtension(this.filename)}${extension}`; + this.triggerDownload(content, filename, mimeType); + + }, + downloadHtml: () => { + this.downloadPopup.style.display = 'none'; + const textarea = this.options.textarea; + if (!textarea) return; + const html = this.htmlWrapper(); + const filename = `${filenameWithoutPathOrExtension(this.filename)}.idoc.html`; + this.triggerDownload(html, filename, 'text/html'); + }, + }; + + this.render(); + } + + public htmlWrapper() { + if (this.mode === 'markdown') { + return hw.default.htmlMarkdownWrapper(this.filename, this.options.textarea.value); + } else if (this.mode === 'json') { + return hw.default.htmlJsonWrapper(this.filename, this.options.textarea.value); + } + } + + addChildren(children: JSX.Element) { + this.props.children = children; + this.render(); + } + + render() { + mount(ToolbarElement(this.props), this.toolbarElement); + this.downloadButton = this.toolbarElement.querySelector('#download') as HTMLButtonElement; + this.downloadPopup = this.toolbarElement.querySelector('#downloadPopup') as HTMLDivElement; + } + + // Helper method to trigger a download + private triggerDownload(content: string, filename: string, mimeType: string) { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + setTimeout(() => { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 0); + } + + showTweakButton() { + this.props.tweakDisplay = ''; + this.render(); + } + + showRestartButton() { + this.props.restartDisplay = ''; + this.render(); + } + + showDownloadButton() { + this.props.downloadDisplay = ''; + this.render(); + } + + manageTextareaVisibilityForAgents() { + const { textarea } = this.options; + + if (!textarea) { + throw new Error('Textarea element not found'); + } + + // Bot-friendly content hiding strategy: + // 1. Bots that don't execute JS will see the textarea content in full + // 2. Bots that execute JS but have short timeouts will see content for 300ms + // 3. Human users see no visual flash due to flex:0 + removed padding/border + textarea.style.flex = '0'; // Collapse to zero width, no visual space + textarea.style.padding = '0'; // Remove default padding that creates pixel shift + textarea.style.border = '0'; // Remove default border that creates pixel shift + setTimeout(() => { + // After 300ms (longer than most bot JS timeouts), restore defaults and hide + textarea.style.flex = ''; // Restore original flex value from CSS + textarea.style.padding = ''; // Restore original padding from CSS + textarea.style.border = ''; // Restore original border from CSS + textarea.style.display = 'none'; // Fully hide from users (but they can unhide to edit) + }, 300); + + } +} + +function filenameWithoutPathOrExtension(filename: string) { + // Remove everything before the last slash or backslash + const base = filename.split(/[\\/]/).pop() || filename; + // If .idoc appears, remove it and everything after + const idocIdx = base.indexOf('.idoc'); + if (idocIdx !== -1) { + return base.substring(0, idocIdx); + } + // Otherwise, remove the last extension if present + const lastDot = base.lastIndexOf('.'); + if (lastDot > 0) { + return base.substring(0, lastDot); + } + return base; +} + +// --- TESTS for filenameWithoutPathOrExtension --- + +// const filenameTests: [string, string][] = [ +// // [input, expected] +// ["foo.md", "foo"], +// ["foo.idoc.md", "foo"], +// ["foo.idoc.json", "foo"], +// ["foo", "foo"], +// ["foo.bar.baz.md", "foo.bar.baz"], +// ["C:\\folder\\foo.md", "foo"], +// ["/home/user/foo.md", "foo"], +// ["folder/foo.idoc.md", "foo"], +// ["folder\\foo.idoc.json", "foo"], +// ["folder.with.dots/foo.bar.baz.md", "foo.bar.baz"], +// ["folder.with.dots\\foo.bar.baz.md", "foo.bar.baz"], +// ["", ""], +// ]; + +// filenameTests.forEach(([input, expected], i) => { +// const result = filenameWithoutPathOrExtension(input); +// const pass = result === expected; +// // eslint-disable-next-line no-console +// console.log( +// `${pass ? '✅' : '❌'} Test ${i + 1}: ${pass ? 'PASS' : `FAIL\n Input: ${input}\n Got: ${result}\n Expected: ${expected}`}` +// ); +// }); + diff --git a/packages/toolbar/tsconfig.json b/packages/toolbar/tsconfig.json index 4b1e9828..cdb23210 100644 --- a/packages/toolbar/tsconfig.json +++ b/packages/toolbar/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "../tsconfig.base.json", "compilerOptions": { + "jsx": "react", + "jsxFactory": "createElement", "outDir": "dist/esnext" }, "include": [