Skip to content

Latest commit

 

History

428 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Marking Menu

NPM CI Deploy

A JavaScript marking menu for mouse, touch, and pen input. Press and pause to show the menu, then move toward an item and release to select it. Once you know the directions, draw the gesture without waiting for the menu.

Try the demo or build your own menu in the playground.

Install

npm install marking-menu

The package uses ES modules, includes its styles, and ships TypeScript declarations. Create menus in the browser after the parent element exists.

Browser without a bundler

Add this import map before your module script:

<script type="importmap">
  {
    "imports": {
      "marking-menu": "https://esm.sh/marking-menu@1?raw"
    }
  }
</script>

Usage

Give the menu a container with room for gestures:

<div id="menu-area" style="position: relative; height: 400px"></div>

Run this in your application, or in a <script type="module"> after the container:

import { createMarkingMenu } from 'marking-menu';

const menu = createMarkingMenu({
  parent: document.getElementById('menu-area'),
  items: [
    { label: 'Copy' },
    {
      label: 'More',
      items: [{ label: 'Duplicate' }, { label: 'Delete' }],
    },
    { label: 'Paste' },
    { label: 'Undo' },
  ],
});

menu.on('select', (event) => {
  console.log(event.selection.label);
});

The menu listens immediately. Here, Copy is right, More is down, Paste is left, and Undo is up. Pause over More to open its submenu.

Call menu.dispose() when the container is removed or you no longer need the menu. It stops listening and removes the elements the menu created.

API

createMarkingMenu({ items, parent, ...options })

Returns an active controller. The required properties are:

  • parent: The HTMLElement that receives pointer input and contains the menu.
  • items: An array of item objects. Each has a required label string and optional id, angle, and nested items. Labels display as plain text. IDs are strings and must be unique across the whole menu. A nonempty items array makes an item a submenu.

See item layout for angles. TypeScript infers event and item types from your configuration; a selected item's id is undefined if you did not supply one.

Options

Pass options alongside items and parent. Distances use pixels; delays use milliseconds.

Option Default Purpose
noviceDwellingTime 1000 / 3 Pause before showing the menu.
submenuOpeningDelay 1000 / 3 Pause before opening a submenu.
movementsThreshold 5 Movement needed to start a gesture without opening the menu, or restart a submenu pause.
deadZoneRadius 40 Distance from the menu center past which an item becomes active and can open a submenu.

Use log: { error: handler } to handle internal errors; the default is console.error. Invalid menu definitions throw during construction.

Events and cleanup

Use menu.on(type, listener) to register a listener and menu.off(type, listener) to remove that same listener.

Event When it fires
start A gesture begins.
open A menu or submenu opens.
move The pointer moves during a gesture.
change The active item changes while a menu is open.
select A gesture ends with a selected item.
cancel A gesture ends without a selection.

select carries the selected item as event.selection, including its id and label.

Every event includes position, a viewport [x, y] pair, and mode: startup while waiting for movement or a pause, novice while using a visible menu, or expert while drawing a gesture. See the event types for each payload.

dispose() ends the controller's lifetime and can be called more than once. The controller also supports using where your toolchain supports it.

Item layout

Angles are clockwise degrees from the right: 0 is right, 90 is down, 180 is left, and 270 is up. Each menu level uses its own layout; submenu angles are measured from the right, not from the parent's direction.

Items keep their clockwise array order. Stated angles must be finite and distinct after wrapping into [0, 360). They must fit within one clockwise turn in array order: 270, 0, 90 is valid; 0, 270, 180 is not.

Every gap between neighboring items, including the last and first, must be at least 45 degrees. Construction throws if a gap is smaller, so at most eight items fit in a level.

Items without an angle are spaced as follows. Results below follow array order and use degrees:

Stated angles Placement Example
None Equal spacing around the circle, starting at 0. Four items: 0, 90, 180, 270.
One Equal spacing, rotated to keep that item at its angle. Four items, third at 90: 270, 0, 90, 180.
Several Equal spacing within each gap between stated angles, including the gap back to the first. Five items, first at 0 and fourth at 180: 0, 60, 120, 180, 270.

For the last example, paste this into the playground:

{
  "items": [
    { "label": "First", "angle": 0 },
    { "label": "Second" },
    { "label": "Third" },
    { "label": "Fourth", "angle": 180 },
    { "label": "Fifth" }
  ]
}

Second and Third split the first 180-degree gap into three 60-degree steps. Fifth splits the remaining 180-degree gap in half, landing at 270.

Appearance

Each open menu uses a library-created <div class="marking-menu"> with an open shadow root. The root is available for inspection, but direct mutation is unsupported. Use .marking-menu as the stable host selector for custom properties and ::part() rules.

Scope the host selector to a container when only one menu should change:

#menu-area .marking-menu {
  --mm-plate-background: #222;
  --mm-plate-color: #fff;
  --mm-plate-background-active: #444;
  --mm-plate-color-active: #fff;
}

Lengths accept CSS length values, including em, rem, and calc(). Colors accept any CSS color value. Layout and stroke values are resolved when a menu opens.

Plate and label properties

Property Default Purpose
--mm-plate-background #f2f2f2 Plate background.
--mm-plate-color #333333 Label color.
--mm-plate-background-active #d9d9d9 Active plate background.
--mm-plate-color-active #000 Active label color.
--mm-plate-padding 12px Space around the label.
--mm-plate-corner-radius 16px Plate corner radius.
--mm-plate-font-size 18px Label font size.
--mm-label-min-width 0 Minimum plate width.
--mm-label-max-width none Maximum plate width before label truncation.

Browsers without text-box-trim and text-box-edge support add 0.2em to the configured plate padding so text is not clipped vertically.

Wedge and ring properties

Property Default Purpose
--mm-wedge-thickness 40px Distance from deadZoneRadius to the ring's outside.
--mm-wedge-fill Plate background Wedge fill.
--mm-wedge-fill-active Active plate background Active wedge fill.
--mm-wedge-gap 4px Constant-width gap between wedges.
--mm-wedge-corner-radius 4px Wedge corner radius.

Connector properties

Property Default Purpose
--mm-connector-thickness 4px Connector thickness.
--mm-inner-connector-color transparent Center-to-ring connector color.
--mm-outer-connector-color Plate background Ring-to-plate connector color.

The active outer connector uses the active plate background unless --mm-outer-connector-color is set. A set value applies in both states.

Layout clearance properties

Property Default Purpose
--mm-plate-gap-horizontal 14px Horizontal clearance between plates.
--mm-plate-gap-vertical 4px Vertical clearance between plates.
--mm-plate-gap-ring 12px Clearance between a plate and the ring.
--mm-plate-gap-connector 4px Clearance from a plate to another item's outer connector.

Stroke properties

Property Default Purpose
--mm-stroke-color #000000 Current gesture color.
--mm-stroke-width 4px Current gesture width.
--mm-stroke-start-point-radius 8px Novice-mode start marker radius.
--mm-stroke-color-lower #777777 Earlier gesture segments' color.
--mm-stroke-width-lower --mm-stroke-width Earlier gesture segments' width.
--mm-stroke-start-point-radius-lower --mm-stroke-width-lower Earlier gesture start marker radius.
--mm-stroke-color-feedback --mm-stroke-color Selected gesture feedback color.
--mm-stroke-width-feedback --mm-stroke-width Completed gesture feedback width.
--mm-stroke-color-canceled #de6c52 Canceled gesture feedback color.

Parts

Parts expose whole menu elements when custom properties are not enough:

Part Active modifier Element
ring None The ring's SVG element.
wedge wedge--active One wedge path.
inner-connector inner-connector--active One center-to-ring connector.
outer-connector outer-connector--active One ring-to-plate connector.
plate plate--active One label plate.
label label--active One label's text element.

For example, this rule adds an outline only to the active plate:

#menu-area .marking-menu::part(plate--active) {
  outline: 2px solid currentColor;
}

The stroke canvases are light-DOM siblings of .marking-menu, not shadow parts. Theme their pixels with the stroke custom properties.

A part selector cannot use combinators, class selectors, ID selectors, attribute selectors, or structural pseudo-classes after ::part(). It also cannot chain through another shadow root. Add a rule for each exposed part instead of selecting its descendants or position.

Input behavior

Gestures start with the primary mouse button, primary touch contact, or primary pen contact. The controller sets the parent's inline touch-action to none !important for its lifetime, preventing browser touch gestures in that area.

Once all controllers sharing the parent are disposed, the previous inline value and priority are restored, unless your application changed the property in the meantime.

Upgrading from 0.10.1

Default item positions change in 5-, 6-, and 7-item menus. The layout change causes no error or build failure, but learned gestures can select different items. Set each item's angle to preserve its previous direction.

The release also changes imports, menu configuration, and event handling. Use the named createMarkingMenu export with a configuration object and register listeners with on. Replace subscription cleanup with dispose().

Appearance and theming

Menus now render inside an open shadow root. Page CSS cannot reach the internal class names. .marking-menu remains the host selector, but these selectors stop working:

Old selector or state Replacement
.marking-menu-item None. Style its exposed child parts.
.marking-menu-label .marking-menu::part(label) for text or ::part(plate) for its box.
.marking-menu-line ::part(inner-connector) and ::part(outer-connector).
.marking-menu.solved None. Solved layout is internal.
.marking-menu-item.active ... The corresponding --active part modifier.
.bottom-right-item, .bottom-left-item, .top-left-item, and .top-right-item --mm-plate-corner-radius applies to every plate corner.

The old box-model rule for .marking-menu, .marking-menu * is also gone. The shadow boundary keeps page-wide box sizing rules out of the menu.

Replace the old custom properties as follows:

Old property Replacement
--item-width --mm-label-min-width and --mm-label-max-width.
--item-height None. Plate height follows the label and padding.
--item-font-size --mm-plate-font-size.
--item-padding --mm-plate-padding.
--item-background --mm-plate-background.
--item-color --mm-plate-color.
--active-item-background --mm-plate-background-active.
--active-item-color --mm-plate-color-active.
--item-radius --mm-plate-corner-radius.
--menu-radius --mm-wedge-thickness, measured outward from deadZoneRadius.
--center-radius None. It was unused.
--line-thickness --mm-connector-thickness.
--line-color --mm-outer-connector-color.
--active-line-color ::part(outer-connector--active) when it differs from the active plate.
--item-horizontal-gap --mm-plate-gap-horizontal.
--item-vertical-gap --mm-plate-gap-vertical.
--item-ring-gap --mm-plate-gap-ring.
--item-connector-gap --mm-plate-gap-connector.

Label plates now hug their text. Set both width properties to restore the old fixed width and ellipsis:

.marking-menu {
  --mm-label-min-width: 120px;
  --mm-label-max-width: 120px;
}

The visible connector now starts at the ring. Set the inner connector to the same color for a continuous center-to-plate line:

.marking-menu {
  --mm-inner-connector-color: var(
    --mm-outer-connector-color,
    var(--mm-plate-background, #f2f2f2)
  );
}

Nine stroke options moved from createMarkingMenu configuration to CSS:

Removed option Replacement
strokeColor --mm-stroke-color.
strokeWidth --mm-stroke-width.
strokeStartPointRadius --mm-stroke-start-point-radius.
lowerStrokeColor --mm-stroke-color-lower.
lowerStrokeWidth --mm-stroke-width-lower.
lowerStrokeStartPointRadius --mm-stroke-start-point-radius-lower.
gestureFeedbackStrokeWidth --mm-stroke-width-feedback.
gestureFeedbackStrokeColor --mm-stroke-color-feedback.
gestureFeedbackCanceledStrokeColor --mm-stroke-color-canceled.

gestureFeedbackDuration remains a configuration option and still defaults to 1000 milliseconds.

Background and license

This library implements Gordon Kurtenbach's marking menus: paper 1, paper 2, and paper 3.

This codebase is licensed under the MIT license.

Several patents concern marking menus; none belong to this library's author. Make sure you have the rights to use the library in your application. The authors and contributors may not be held responsible for patent infringement resulting from its use.

About

Marking Menu implementation in TypeScript

Topics

Resources

Stars

16 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages