Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/app/studio/src/ui/AnyDragData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import { EffectFactories, InstrumentFactories } from "@opendaw/studio-core";
*/

/** Hint to drop targets that a drag operation should be copied instead of moved. */
export type DragCopyHint = { copy?: boolean };
export type DragCopyHint = {
/** When `true`, receivers should duplicate rather than move the item. */
copy?: boolean;
};

/** Dragged sample originating from the browser's file system or library. */
export type DragSample = { type: "sample"; sample: Sample } & DragCopyHint;
Expand Down
27 changes: 25 additions & 2 deletions packages/app/studio/src/ui/DragAndDrop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,23 @@ import { Events, Keyboard } from "@opendaw/lib-dom";
export namespace DragAndDrop {
let dragging: Option<AnyDragData> = Option.None;

/**
* Tests whether the drag event carries native file data.
*
* @param event Drag event to inspect.
* @returns `true` when the payload contains file information.
*/
const hasFiles = (event: DragEvent): boolean => {
const type = event.dataTransfer?.types?.at(0);
return type === "Files" || type === "application/x-moz-file";
};

/**
* Extracts `File` objects from a drag event when available.
*
* @param event Drag event to inspect.
* @returns Array of files or an empty array.
*/
const extractFiles = (event: DragEvent): ReadonlyArray<File> => {
const dataTransfer = event.dataTransfer;
if (!isDefined(dataTransfer)) {
Expand Down Expand Up @@ -67,9 +79,20 @@ export namespace DragAndDrop {

/** Callbacks describing the drop target behaviour. */
export interface Process {
/** Called during dragover/dragenter to determine whether dropping is allowed. */
/**
* Called during `dragover`/`dragenter` to determine whether dropping is allowed.
*
* @param event Native drag event.
* @param dragData Data supplied by the drag source.
* @returns `true` when a drop should be permitted.
*/
drag(event: DragEvent, dragData: AnyDragData): boolean;
/** Called when a drop happens and `drag` returned true. */
/**
* Called when a drop happens and `drag` returned true.
*
* @param event Native drag event.
* @param dragData Data supplied by the drag source.
*/
drop(event: DragEvent, dragData: AnyDragData): void;
/** Invoked when the first dragged item enters the target. */
enter(allowDrop: boolean): void;
Expand Down
29 changes: 25 additions & 4 deletions packages/app/studio/src/ui/spotlight/Spotlight.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,17 @@ import { Dragging, Events, Html, Keyboard } from "@opendaw/lib-dom";
const className = Html.adoptStyleSheet(css, "Spotlight");

export namespace Spotlight {
/** Installs global keyboard shortcuts to toggle the spotlight view. */
export const install = (surface: Surface, service: StudioService) => {
/**
* Installs global keyboard shortcuts to toggle the spotlight view.
*
* @param surface Drawing surface used as the parent for the overlay.
* @param service Application service providing data sources.
* @returns Terminable subscription managing the shortcut listeners.
*/
export const install = (
surface: Surface,
service: StudioService,
): Terminable => {
const position = Point.create(surface.width / 2, surface.height / 3);
let current: Nullable<HTMLElement> = null;
return Terminable.many(
Expand Down Expand Up @@ -68,19 +77,31 @@ export namespace Spotlight {

/** Props required to render the spotlight view. */
type Construct = {
/** Terminator controlling component cleanup. */
terminator: Terminator;
/** Surface on which the overlay is rendered. */
surface: Surface;
/** Access to services and command providers. */
service: StudioService;
/** Initial position of the overlay. */
position: Point;
};

/** Floating spotlight search box rendering component. */
/**
* Floating spotlight search box rendering component.
*
* @param terminator Aggregates disposables of the view.
* @param surface Drawing surface hosting the overlay.
* @param service Application service providing data sources.
* @param position Initial position of the overlay.
* @returns Root HTML element of the spotlight view.
*/
export const View = ({
terminator,
surface,
service,
position,
}: Construct) => {
}: Construct): HTMLElement => {
const query = new DefaultObservableValue("");
const inputField: HTMLInputElement = SearchInput({
lifecycle: terminator,
Expand Down
7 changes: 5 additions & 2 deletions packages/app/studio/src/ui/timeline/TimelineNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ type Construct = {
*
* @remarks
* Place this component directly beneath the {@link TimelineHeader}.
*
* @param lifecycle Controls the lifetime of nested components.
* @param service Application service providing timeline state.
*/
export const TimelineNavigation = ({lifecycle, service}: Construct) => {
export const TimelineNavigation = ({ lifecycle, service }: Construct) => {
const {range, snapping} = service.timeline
const {editing, timelineBox} = service.project
return (
Expand All @@ -42,4 +45,4 @@ export const TimelineNavigation = ({lifecycle, service}: Construct) => {
range={range}/>
</div>
)
}
}
31 changes: 31 additions & 0 deletions packages/docs/docs-dev/dom/input-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Input Handling

Utilities for dealing with user input live in `@opendaw/lib-dom`.
They provide typed wrappers around DOM events, keyboard shortcuts and
pointer-based dragging.

```ts
import { Events, Dragging, Keyboard } from "@opendaw/lib-dom";
import { Option } from "@opendaw/lib-std";

Events.subscribe(window, "pointerdown", (ev) => {
console.log("pointer", ev.clientX, ev.clientY);
});

Dragging.attach(element, (start) =>
Option.some({
update: (ev) => console.log(ev.clientX - start.clientX),
}),
);

window.addEventListener("keydown", (e) => {
if (Keyboard.GlobalShortcut.isDelete(e)) {
console.log("delete selection");
}
});
```

See [`events.ts`](./events.md), [`dragging.ts`](./dragging.md) and
[`keyboard.ts`](./keyboard.md) for detailed documentation of the
individual modules.

4 changes: 3 additions & 1 deletion packages/docs/docs-user/browser-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
openDAW runs in current versions of Chrome, Firefox, and Edge (Chromium).
Safari currently lacks the cross‑origin isolation features required for
`SharedArrayBuffer` and WebAssembly threads, so the Studio does not yet run
there.
there. Modern input features such as pointer events and drag‑and‑drop are
also required and are best supported in these browsers.

For the best experience, keep your browser up to date. Some features—such as
MIDI access or advanced file APIs—may be limited depending on the browser.
Refer to the [developer browser support](../docs-dev/browser-support.md) for
technical details and version targets.

63 changes: 48 additions & 15 deletions packages/lib/dom/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,27 +25,60 @@ export namespace Browser {
typeof self !== "undefined" &&
"navigator" in self &&
typeof self.navigator !== "undefined";
/** Determines whether the current host is a localhost instance. */
export const isLocalHost = () =>
/**
* Determines whether the current host is a localhost instance.
*
* @returns `true` when `location.host` contains `"localhost"`.
*/
export const isLocalHost = (): boolean =>
hasLocation && location.host.includes("localhost");
/** True when the user agent indicates macOS. */
export const isMacOS = () =>
/**
* Checks whether the user agent indicates macOS.
*
* @returns `true` for macOS platforms.
*/
export const isMacOS = (): boolean =>
hasNavigator && navigator.userAgent.includes("Mac OS X");
/** True when the user agent indicates Windows. */
export const isWindows = () =>
/**
* Checks whether the user agent indicates Windows.
*
* @returns `true` for Windows platforms.
*/
export const isWindows = (): boolean =>
hasNavigator && navigator.userAgent.includes("Windows");
/** True when the user agent is Firefox. */
export const isFirefox = () =>
/**
* Checks whether the user agent is Firefox.
*
* @returns `true` when the browser is Firefox.
*/
export const isFirefox = (): boolean =>
hasNavigator && navigator.userAgent.toLowerCase().includes("firefox");
/** True when running in a web context instead of the Tauri app. */
export const isWeb = () => !isTauriApp();
/** Detects vitest environment via `process.env`. */
/**
* Checks whether openDAW runs inside a regular web page instead of the
* Tauri desktop application.
*
* @returns `true` for web contexts.
*/
export const isWeb = (): boolean => !isTauriApp();
/**
* Detects whether the environment is a Vitest test run via `process.env`.
*
* @returns `true` when executing inside Vitest.
*/
export const isVitest =
typeof process !== "undefined" && process.env?.VITEST === "true";
/** True when running inside a Tauri application. */
export const isTauriApp = () => "__TAURI__" in window;
/** Normalised user agent string or `"N/A"` when unavailable. */
export const userAgent = hasNavigator
/**
* Checks whether the code runs inside a Tauri application.
*
* @returns `true` when the `__TAURI__` object is present.
*/
export const isTauriApp = (): boolean => "__TAURI__" in window;
/**
* Normalised user agent string or `"N/A"` when unavailable.
*
* @returns User agent description without redundant tokens.
*/
export const userAgent: string = hasNavigator
? navigator.userAgent
.replace(/^Mozilla\/[\d.]+\s*/, "")
.replace(/\bAppleWebKit\/[\d.]+\s*/g, "")
Expand Down
3 changes: 3 additions & 0 deletions packages/lib/dom/src/constraint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export namespace ConstrainDOM {
* const value = ConstrainDOM.resolveString(constraint);
* // value === "video,audio"
* ```
*
* @param constrain Constraint value to resolve.
* @returns Comma separated string representation or `undefined`.
*/
export const resolveString = (
constrain: Nullish<ConstrainDOMString>,
Expand Down
11 changes: 9 additions & 2 deletions packages/lib/dom/src/dragging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,15 @@ import { Events, PointerCaptureTarget } from "./events";
import { Keyboard } from "./keyboard";

export namespace Dragging {
/**
* Callbacks controlling the lifecycle of a drag interaction.
*/
export interface Process {
/** Receives updated pointer information. */
/**
* Receives updated pointer information.
*
* @param event Normalised pointer movement data.
*/
update(event: Event): void;
/** Invoked when the drag is cancelled. */
cancel?(): void;
Expand Down Expand Up @@ -56,7 +63,7 @@ export namespace Dragging {

/**
* Attaches pointer listeners to `target` and creates a dragging lifecycle
* managed by a `Process` instance produced by `factory`.
* managed by a {@link Process} instance produced by `factory`.
*
* @param target Element initiating the pointer interaction.
* @param factory Produces a {@link Process} for the drag sequence.
Expand Down
23 changes: 23 additions & 0 deletions packages/lib/dom/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export class Events {
* // ...later
* sub.terminate();
* ```
*
* @param eventTarget Target dispatching the event.
* @param type Name of the event to listen for.
* @param listener Callback handling the event.
* @param options Optional `addEventListener` configuration.
* @returns Subscription object to remove the listener.
*/
static subscribe<K extends keyof KnownEventMap>(
eventTarget: EventTarget,
Expand All @@ -37,6 +43,12 @@ export class Events {

/**
* Subscribes to an arbitrary event by name.
*
* @param eventTarget Target dispatching the event.
* @param type Name of the custom event.
* @param listener Callback handling the event.
* @param options Optional `addEventListener` configuration.
* @returns Subscription object to remove the listener.
*/
static subscribeAny<E extends Event>(
eventTarget: EventTarget,
Expand Down Expand Up @@ -82,12 +94,17 @@ export class Events {

/**
* Convenience handler that calls `preventDefault` on the event.
*
* @param event Event to cancel.
*/
static readonly PreventDefault: Procedure<Event> = (event) =>
event.preventDefault();

/**
* Checks whether the given target is a text input element.
*
* @param target Event target to inspect.
* @returns `true` if the target represents an editable text field.
*/
static readonly isTextInput = (target: Nullable<EventTarget>): boolean =>
target instanceof HTMLInputElement ||
Expand All @@ -96,8 +113,14 @@ export class Events {
isDefined(target.getAttribute("contenteditable")));
}

/**
* Subset of the `Element` interface that supports pointer capture.
*/
export interface PointerCaptureTarget extends EventTarget {
/** Assigns future pointer events with the given id to this target. */
setPointerCapture(pointerId: number): void;
/** Releases previously captured pointer events. */
releasePointerCapture(pointerId: number): void;
/** Tests whether this target currently has capture for the pointer. */
hasPointerCapture(pointerId: number): boolean;
}
11 changes: 10 additions & 1 deletion packages/lib/dom/src/fonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@
* Properties required to load a custom font.
*/
export type FontFaceProperties = {
/** Name used to reference the font in CSS. */
"font-family": string;
/** Font style variant. */
"font-style": "normal" | "italic" | "oblique";
/**
* Weight numeric value or keyword accepted by the `FontFace` constructor.
*/
"font-weight":
| 100
| 200
Expand All @@ -21,6 +26,7 @@ export type FontFaceProperties = {
| "bold"
| "bolder"
| "lighter";
/** URL pointing to the font resource. */
src: string;
};

Expand All @@ -37,8 +43,11 @@ export type FontFaceProperties = {
* });
* document.body.style.fontFamily = "MyFont";
* ```
*
* @param properties Font description and source URL.
* @returns Promise resolving once the font is loaded and registered.
*/
export const loadFont = async (properties: FontFaceProperties) => {
export const loadFont = async (properties: FontFaceProperties): Promise<void> => {
try {
const response = await fetch(properties.src, { credentials: "omit" });
const fontData = await response.arrayBuffer();
Expand Down
Loading