From 7eb97b715be85ebb20cbcd4cbbefcc9d2cccb64d Mon Sep 17 00:00:00 2001 From: Eduardo Omine Date: Wed, 22 Jul 2026 12:48:37 -0300 Subject: [PATCH 1/3] Create UIStylesFloatingMenu component --- lib/css/shell/main.css | 20 ++- .../prosemirror/type-spec.typeroof.jsx | 158 ++++++++++-------- 2 files changed, 104 insertions(+), 74 deletions(-) diff --git a/lib/css/shell/main.css b/lib/css/shell/main.css index 243caaa0..14722874 100644 --- a/lib/css/shell/main.css +++ b/lib/css/shell/main.css @@ -6,6 +6,10 @@ @import url("./docs.css"); @import url("./type-stage.css"); +.hidden { + display: none !important; +} + /****************************************************************************** material symbols *******************************************************************************/ @@ -1958,11 +1962,22 @@ prosemirror menu } } -.ui_prose_mirror_menu-simple { +/****************************************************************************** +styles floating menu +*******************************************************************************/ + +.ui-styles-floating-menu { + background-color: #eee; + border: 1px solid #ddd; + border-radius: 4px; + box-shadow: rgb(0 0 0 / 10%) 0 0.25em 0.5em; display: flex; gap: 0.25em; height: auto; margin-bottom: 1em; + padding: 0.25em; + position: fixed; + z-index: 2; .ui_prose_mirror_menu-container-blocks, .typeroof-ui-label { @@ -1974,9 +1989,8 @@ prosemirror menu padding: 0.25rem; &.active { - color: white; - background: var(--button-active-color); border-color: var(--button-active-color); + outline: 1px solid var(--button-active-color); } } diff --git a/lib/js/components/prosemirror/type-spec.typeroof.jsx b/lib/js/components/prosemirror/type-spec.typeroof.jsx index f5f0f739..ee4516ea 100644 --- a/lib/js/components/prosemirror/type-spec.typeroof.jsx +++ b/lib/js/components/prosemirror/type-spec.typeroof.jsx @@ -1824,116 +1824,132 @@ export class UIProseMirrorMenu extends _IDPublisherMixin( } } -export class UIBoldItalicMenu extends _BaseComponent { +class UIStylesFloatingMenu extends _BaseComponent { constructor(widgetBus, originTypeSpecPath) { super(widgetBus); this._originTypeSpecPath = originTypeSpecPath; - this._styleToButton = new Map(); - [this.element] = this._initTemplate(); + this._onSelectInstance = this._onSelectInstance.bind(this); + this._onWindowPointerDown = this._onWindowPointerDown.bind(this); + this._reposition = this._reposition.bind(this); + [this.element, this._select] = this._initTemplate(); } _getTemplate(h) { return ( -
- - +
+
); } _initTemplate() { - const container = this._getTemplate(this._domTool.h), - boldButton = container.querySelector("[data-style=bold]"), - italicButton = container.querySelector("[data-style=italic]"); + const container = this._getTemplate(this._domTool.h); + const select = container.querySelector("select"); this._insertElement(container); - this._styleToButton.set("bold", boldButton); - this._styleToButton.set("italic", italicButton); - container.addEventListener( - "pointerdown", - this._stylesClickHandler.bind(this), - ); - return [container]; + select.addEventListener("change", this._onSelectInstance); + return [container, select]; } - _stylesClickHandler(event) { - event.preventDefault(); - const button = event.target.closest("button"); - if (!button) return; - const styleName = button.getAttribute("data-style"); - if (!this._styleToButton.has(styleName) || !this._editorView) return; - if (button.disabled) return; - - const { dispatch, state } = this._editorView, - markType = state.schema.marks["generic-style"], - [, activeMarks] = getActiveNodesAndMarks(state), - genericStyleMark = state.schema.marks["generic-style"], - activeStyles = activeMarks.get(genericStyleMark) || [], - activeStylesSeparated = Array.from(activeStyles).flatMap((s) => - s.split(" "), - ); - let newStyleName = activeStylesSeparated.includes(styleName) - ? activeStylesSeparated.filter((s) => s !== styleName).join(" ") - : activeStylesSeparated.concat(styleName).toSorted().join(" "); + _onSelectInstance() { + if (!this._editorView) return; - // newStyleName must not be "" (the empty string). The result of - // no mark is achieved by removing the active mark, and that is the - // job of `toggleMark`. In the case of an "" newStyleName should - // just be styleName and toggleMark will remove it. - if (newStyleName === "") newStyleName = styleName; + const { dispatch, state } = this._editorView; + const markType = state.schema.marks["generic-style"]; + const styleName = this._select.value; toggleMark( markType, - { "data-style-name": newStyleName }, + { "data-style-name": styleName }, {}, )(state, dispatch); } + _onWindowPointerDown(e) { + if (e.target.closest(".ProseMirror")) return; + if (e.target.closest(`.${this.element.className}`)) return; + this._hide(); + } + + _hide() { + this.element.classList.add("hidden"); + window.removeEventListener("pointerdown", this._onWindowPointerDown); + } + + _show() { + this.element.classList.remove("hidden"); + window.addEventListener("pointerdown", this._onWindowPointerDown); + this._reposition(); + } + + _reposition() { + const { state } = this._editorView; + const { from } = state.selection; + const coords = this._editorView.coordsAtPos(from); + this.element.style.left = `${coords.left}px`; + this.element.style.top = `${coords.bottom}px`; + if (!this.element.classList.contains("hidden")) { + requestAnimationFrame(this._reposition); + } + } + _getTypeSpecPropertiesId = getTypeSpecPropertiesIdMethod; _getTypeSpecs = getTypeSpecsMethod; + _updateDropdown() { + this._select.replaceChildren(); + + const font = this.getEntry("font").value; + if (font.instances.length === 0) return; + + const index = font.fontObject.variation.getDefaultInstanceIndex(); + this._defaultValue = font.instances[index][0].toLowerCase(); + + font.instances.forEach((instance) => { + const option = this._domTool.createElement("option"); + const value = instance[0].toLowerCase(); + option.value = value; + option.textContent = instance[0]; + option.selected = value === this._defaultValue; + this._select.append(option); + }); + } + updateView(view) { if (!view) return; this._editorView = view; - const state = this._editorView.state, - setsOfStyles = new Map(), - allStylesSuperSet = new Set(["bold", "italic", "bold italic"]), - commonSubSet = new Set(); - - for (const style of allStylesSuperSet) { - if ( - setsOfStyles.values().every((stylesSet) => stylesSet.has(style)) - ) { - commonSubSet.add(style); - } + if (this._select.childElementCount === 0) return; + + const { state } = this._editorView; + const [, activeMarks] = getActiveNodesAndMarks(state); + const genericStyleMark = state.schema.marks["generic-style"]; + const activeStyles = activeMarks.get(genericStyleMark); + this._select.value = activeStyles + ? Array.from(activeStyles)[0] + : this._defaultValue; + + if (state.selection.empty) { + this._hide(); + return; } - const [, activeMarks] = getActiveNodesAndMarks(state), - genericStyleMark = state.schema.marks["generic-style"], - activeStyles = activeMarks.get(genericStyleMark) || [], - activeStylesSeparated = new Set( - Array.from(activeStyles).flatMap((s) => s.split(" ")), - ); - for (const styleName of allStylesSuperSet) { - const button = this._styleToButton.get(styleName); - if (!button) continue; - - button.disabled = !commonSubSet.has(styleName); - button.classList[ - activeStylesSeparated.has(styleName) ? "add" : "remove" - ]("active"); - } + this._show(); } destroyView() { this._editorView = null; } + destroy() { + this._select.removeEventListener("change", this._onSelectInstance); + window.removeEventListener("pointerdown", this._onWindowPointerDown); + } + update(changedMap) { + if (changedMap.has("font")) { + this._updateDropdown(); + } + if (changedMap.has("nodeSpecToTypeSpec")) { this.updateView(this._editorView); } From 88bef021e0196e453f331637802301c807030ee3 Mon Sep 17 00:00:00 2001 From: Eduardo Omine Date: Wed, 22 Jul 2026 12:49:04 -0300 Subject: [PATCH 2/3] Create style patches dynamically --- .../layouts/ramp/index.typeroof.jsx | 6 + .../layouts/ramp/root-font-style-patches.mjs | 140 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 lib/js/components/layouts/ramp/root-font-style-patches.mjs diff --git a/lib/js/components/layouts/ramp/index.typeroof.jsx b/lib/js/components/layouts/ramp/index.typeroof.jsx index f8bbeb3c..0ca359d0 100644 --- a/lib/js/components/layouts/ramp/index.typeroof.jsx +++ b/lib/js/components/layouts/ramp/index.typeroof.jsx @@ -7,12 +7,14 @@ import { PathModelOrEmpty, Path, BooleanModel, + StringModel, CoherenceFunction, } from "../../../metamodel.mjs"; import { TypeSpecModel, StylePatchesMapModel, } from "../../type-spec-models.mjs"; +import { createRootFontStylePatchesCoherenceFunction } from "./root-font-style-patches.mjs"; import { ProseMirrorSchemaModel, NodeSpecToTypeSpecMapModel, @@ -63,7 +65,11 @@ const RampModel = _BaseLayoutModel.createClass( // the root of all typeSpecs ["document", NodeModel], ["showParameters", BooleanModel], + // The font identity for which the style patches in "stylePatchesSource" + // were generated, see createRootFontStylePatchesCoherenceFunction. + ["rootFontStylePatchesKey", StringModel], initTypeSpecCoherenceFn(DEFAULT_STATE), + createRootFontStylePatchesCoherenceFunction(), ); class TypeSpecSelect extends GenericSelect { diff --git a/lib/js/components/layouts/ramp/root-font-style-patches.mjs b/lib/js/components/layouts/ramp/root-font-style-patches.mjs new file mode 100644 index 00000000..00f1071b --- /dev/null +++ b/lib/js/components/layouts/ramp/root-font-style-patches.mjs @@ -0,0 +1,140 @@ +import { + CoherenceFunction, + deserializeSync, + SERIALIZE_OPTIONS, + SERIALIZE_FORMAT_OBJECT, +} from "../../../metamodel.mjs"; +import { + StylePatchesMapModel, + StylePatchLinksMapModel, +} from "../../type-spec-models.mjs"; + +const _SERIALIZE_OPTIONS = Object.assign({}, SERIALIZE_OPTIONS, { + format: SERIALIZE_FORMAT_OBJECT, +}); + +/** + * Depth first iteration over the root TypeSpec and all of its (recursive) + * children. + */ +function* _iterTypeSpecs(typeSpec) { + yield typeSpec; + for (const [, child] of typeSpec.get("children").entries()) + yield* _iterTypeSpecs(child); +} + +/** + * Replace the complete contents of an ordered map (`target`) with the + * entries of `newMap`. + */ +function _replaceOrderedMap(target, newMap) { + // NOTE: the public "splice" addresses entries by key, "arraySplice" + // by index — here we want to drop all existing entries by index. + if (target.size) target.arraySplice(0, target.size); + for (const [key, entry] of newMap.entries()) target.set(key, entry); +} + +/** + * An "observer" implemented as a CoherenceFunction, reacting to changes + * of the root font (the "font" dependency). + * + * Whenever the root font changes it (re)generates all "style patches" — + * one SimpleStylePatch per named instance of the font — into + * `stylePatchesSource`, and associates those patches with every existing + * TypeSpec (the root TypeSpec and all of its children). + * + * NOTE: a CoherenceFunction is the idiomatic way to react to model + * changes here, as changeState (and hence writing the model from a + * component's update phase) is locked while the UI updates. + * + * The font identity for which the patches were generated is stored in + * `rootFontStylePatchesKey`. As coherence functions run on (almost) + * every metamorphose, this lets us rebuild only when the font actually + * changed — and otherwise leave the user's edits to the patches and + * links untouched. + */ +export function createRootFontStylePatchesCoherenceFunction() { + return CoherenceFunction.create( + [ + // Ensures this runs after the default-state initialization, + // as both write "typeSpec" and "stylePatchesSource". + "initTypeSpec", + "font", + "typeSpec", + "stylePatchesSource", + "rootFontStylePatchesKey", + ], + function generateRootFontStylePatches({ + font, + typeSpec, + stylePatchesSource, + rootFontStylePatchesKey, + }) { + // "font" is a ForeignKey/ValueLink that can be null and its + // value is the actual (VideoProof-)font. + const fontObject = font ? font.value : null; + if (!fontObject) return; + + const signature = fontObject.fullName; + if (rootFontStylePatchesKey.value === signature) + // The style patches already correspond to this font, don't + // regenerate (and don't clobber the user's edits). + return; + + // [[name, coordinates {axisTag: numericValue}], ...] + const instances = fontObject.instances; + + // (Re)generate the style patches, one SimpleStylePatch per + // named instance of the font. The instance name is normalized to + // lower case so the keys match the (lower case) style names used + // e.g. in the editor/document (e.g. "bold", "bold italic"). + const stylePatchesData = instances.map(([name, coordinates]) => [ + name.toLowerCase(), + { + stylePatchTypeKey: "SimpleStylePatch", + instance: { + axesLocations: Object.entries(coordinates).map( + ([axisTag, numericValue]) => [ + axisTag, + { logicalValue: "number", numericValue }, + ], + ), + }, + }, + ]); + _replaceOrderedMap( + stylePatchesSource, + deserializeSync( + StylePatchesMapModel, + stylePatchesSource.dependencies, + stylePatchesData, + _SERIALIZE_OPTIONS, + ), + ); + + // Associate the generated style patches with every TypeSpec. + // Keys are the local names in the TypeSpec, values are the keys + // into stylePatchesSource; here both are the instance name. + const stylePatchLinksData = instances.map(([name]) => { + const key = name.toLowerCase(); + return [key, key]; + }); + // Materialize the traversal before mutating, to keep traversal + // and (nested draft) mutation cleanly separated. + for (const typeSpecItem of [..._iterTypeSpecs(typeSpec)]) { + const stylePatches = typeSpecItem.get("stylePatches"); + _replaceOrderedMap( + stylePatches, + deserializeSync( + StylePatchLinksMapModel, + stylePatches.dependencies, + stylePatchLinksData, + _SERIALIZE_OPTIONS, + ), + ); + } + + rootFontStylePatchesKey.value = signature; + }, + ); +} From 016848d6028e7c391b144b6637dea2c99a4183b1 Mon Sep 17 00:00:00 2001 From: Eduardo Omine Date: Wed, 22 Jul 2026 12:49:12 -0300 Subject: [PATCH 3/3] Replace styles menu --- lib/js/components/prosemirror/type-spec.typeroof.jsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/js/components/prosemirror/type-spec.typeroof.jsx b/lib/js/components/prosemirror/type-spec.typeroof.jsx index ee4516ea..9f6acdfb 100644 --- a/lib/js/components/prosemirror/type-spec.typeroof.jsx +++ b/lib/js/components/prosemirror/type-spec.typeroof.jsx @@ -1806,10 +1806,9 @@ export class UIProseMirrorMenu extends _IDPublisherMixin( ], [ { ...menuSettings, id: new.target.ID_MAP.menuStyles }, - ["typeSpec", "nodeSpecToTypeSpec"], - UIProseMirrorMenuStyles, + ["nodeSpecToTypeSpec", ["/font", "font"]], + UIStylesFloatingMenu, originTypeSpecPath, - "Styles:", ], ]; super(widgetBus, zones, widgets);