Skip to content

Commit

Permalink
ntp: handle drag + drop outside of favorites (#1256)
Browse files Browse the repository at this point in the history
* ntp: handle drag + drop outside of favorites

* don't prevent drop in playwright tests

* linting

* fixed format

---------

Co-authored-by: Shane Osbourne <[email protected]>
  • Loading branch information
shakyShane and Shane Osbourne authored Nov 22, 2024
1 parent c9f7798 commit ae441a9
Show file tree
Hide file tree
Showing 10 changed files with 274 additions and 58 deletions.
11 changes: 9 additions & 2 deletions special-pages/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,15 @@ for (const buildJob of buildJobs) {
if (DEBUG) console.log('\t- import.meta.env: ', NODE_ENV);
if (DEBUG) console.log('\t- import.meta.injectName: ', buildJob.injectName);
if (!DRY_RUN) {
buildSync({
const output = buildSync({
entryPoints: buildJob.entryPoints,
outdir: buildJob.outputDir,
bundle: true,
format: 'iife',
// metafile: true,
// minify: true,
// splitting: true,
// external: ['../assets/img/*'],
format: 'iife',
sourcemap: NODE_ENV === 'development',
loader: {
'.js': 'jsx',
Expand All @@ -156,6 +159,10 @@ for (const buildJob of buildJobs) {
},
dropLabels: buildJob.injectName === 'integration' ? [] : ['$INTEGRATION'],
});
if (output.metafile) {
const meta = JSON.stringify(output.metafile, null, 2);
writeFileSync(join(buildJob.outputDir, 'metafile.json'), meta);
}
}
}
for (const inlineJob of inlineJobs) {
Expand Down
2 changes: 2 additions & 0 deletions special-pages/pages/new-tab/app/components/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { h } from 'preact';
import styles from './App.module.css';
import { usePlatformName } from '../settings.provider.js';
import { WidgetList } from '../widget-list/WidgetList.js';
import { useGlobalDropzone } from '../dropzone.js';

/**
* Renders the App component.
Expand All @@ -11,6 +12,7 @@ import { WidgetList } from '../widget-list/WidgetList.js';
*/
export function App({ children }) {
const platformName = usePlatformName();
useGlobalDropzone();
return (
<div className={styles.layout} data-platform={platformName}>
<WidgetList />
Expand Down
13 changes: 11 additions & 2 deletions special-pages/pages/new-tab/app/components/Layout.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { h } from 'preact';

export function Centered({ children }) {
return <div class="layout-centered">{children}</div>;
/**
* @param {object} props
* @param {import("preact").ComponentChild} props.children
* @param {import("preact").ComponentProps<"div">} [props.rest]
*/
export function Centered({ children, ...rest }) {
return (
<div {...rest} class="layout-centered">
{children}
</div>
);
}
106 changes: 106 additions & 0 deletions special-pages/pages/new-tab/app/dropzone.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { useEffect, useRef } from 'preact/hooks';

const REGISTER_EVENT = 'register-dropzone';
const CLEAR_EVENT = 'clear-dropzone';

/**
* Setup the global listeners for the page. This prevents a new tab
* being launched when an unsupported link is dropped in (like from another app)
*/
export function useGlobalDropzone() {
useEffect(() => {
/** @type {HTMLElement[]} */
let safezones = [];
const controller = new AbortController();

/**
* Allow HTML elements to be part of a 'safe zone' where dropping is allowed
*/
window.addEventListener(
REGISTER_EVENT,
(/** @type {CustomEvent} */ e) => {
if (isValidEvent(e)) {
safezones.push(e.detail.dropzone);
}
},
{ signal: controller.signal },
);

/**
* Allow registered HTML elements to be removed from the list of safe-zones
*/
window.addEventListener(
CLEAR_EVENT,
(/** @type {CustomEvent} */ e) => {
if (isValidEvent(e)) {
const match = safezones.findIndex((x) => x === e.detail.dropzone);
safezones.splice(match, 1);
}
},
{ signal: controller.signal },
);

/**
* Use drag over to ensure
*/
document.addEventListener(
'dragover',
(event) => {
if (!event.target) return;
const target = /** @type {HTMLElement} */ (event.target);
if (safezones.length > 0) {
for (const safezone of safezones) {
if (safezone.contains(target)) return;
}
}

// At the moment, this is not supported in the Playwright tests :(
// So we allow the integration build to check first
let preventDrop = true;
// eslint-disable-next-line no-labels,no-unused-labels
$INTEGRATION: (() => {
if (window.__playwright_01) {
preventDrop = false;
}
})();

if (preventDrop) {
// if we get here, we're stopping a drag/drop from being allowed
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'none';
}
}
},
{ signal: controller.signal },
);
return () => {
controller.abort();
safezones = [];
};
}, []);
}

/**
* Register an area allowed to receive drop events
*/
export function useDropzoneSafeArea() {
const ref = useRef(null);
useEffect(() => {
if (!ref.current) return;
const evt = new CustomEvent(REGISTER_EVENT, { detail: { dropzone: ref.current } });
window.dispatchEvent(evt);
return () => {
window.dispatchEvent(new CustomEvent(CLEAR_EVENT, { detail: { dropzone: ref.current } }));
};
}, []);
return ref;
}

/**
* @param {Record<string, any>} input
* @return {input is {dropzone: HTMLElement}}
*/
function isValidEvent(input) {
return 'detail' in input && input.detail.dropzone instanceof HTMLElement;
}
2 changes: 1 addition & 1 deletion special-pages/pages/new-tab/app/entry-points/favorites.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { FavoritesCustomized } from '../favorites/components/FavoritesCustomized

export function factory() {
return (
<Centered>
<Centered data-entry-point="favorites">
<FavoritesCustomized />
</Centered>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Placeholder, PlusIconMemo, TileMemo } from './Tile.js';
import { ShowHideButton } from '../../components/ShowHideButton.jsx';
import { useTypedTranslation } from '../../types.js';
import { usePlatformName } from '../../settings.provider.js';
import { useDropzoneSafeArea } from '../../dropzone.js';

/**
* @typedef {import('../../../../../types/new-tab.js').Expansion} Expansion
Expand All @@ -31,6 +32,7 @@ export const FavoritesMemo = memo(Favorites);
export function Favorites({ gridRef, favorites, expansion, toggle, openContextMenu, openFavorite, add }) {
const platformName = usePlatformName();
const { t } = useTypedTranslation();
const safeArea = useDropzoneSafeArea();

const ROW_CAPACITY = 6;

Expand Down Expand Up @@ -109,7 +111,7 @@ export function Favorites({ gridRef, favorites, expansion, toggle, openContextMe

return (
<div class={cn(styles.root, !canToggleExpansion && styles.bottomSpace)} data-testid="FavoritesConfigured">
<div class={styles.grid} id={WIDGET_ID} ref={gridRef} onContextMenu={onContextMenu} onClick={onClick}>
<div class={styles.grid} id={WIDGET_ID} ref={safeArea} onContextMenu={onContextMenu} onClick={onClick}>
{items.slice(0, expansion === 'expanded' ? undefined : ROW_CAPACITY)}
</div>
{canToggleExpansion && (
Expand Down
148 changes: 100 additions & 48 deletions special-pages/pages/new-tab/app/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,26 @@ import { WidgetConfigProvider } from './widget-list/widget-config.provider.js';
import { Settings } from './settings.js';
import { Components } from './components/Components.jsx';
import { widgetEntryPoint } from './widget-list/WidgetList.js';
import { callWithRetry } from '../../../shared/call-with-retry.js';

/**
* @import {Telemetry} from "./telemetry/telemetry.js"
* @import { Environment } from "../../../shared/environment";
* @param {Element} root
* @param {import("../src/js").NewTabPage} messaging
* @param {import("./telemetry/telemetry.js").Telemetry} telemetry
* @param {import("../../../shared/environment").Environment} baseEnvironment
* @param {Environment} baseEnvironment
* @throws Error
*/
export async function init(messaging, telemetry, baseEnvironment) {
const init = await messaging.init();
export async function init(root, messaging, telemetry, baseEnvironment) {
const result = await callWithRetry(() => messaging.init());

// handle fatal exceptions, the following things prevent anything from starting.
if ('error' in result) {
throw new Error(result.error);
}

const init = result.value;

if (!Array.isArray(init.widgets)) {
throw new Error('missing critical initialSetup.widgets array');
Expand All @@ -28,9 +40,6 @@ export async function init(messaging, telemetry, baseEnvironment) {
throw new Error('missing critical initialSetup.widgetConfig array');
}

// Create an instance of the global widget api
const widgetConfigAPI = new WidgetConfigService(messaging, init.widgetConfigs);

// update the 'env' in case it was changed by native sides
const environment = baseEnvironment
.withEnv(init.env)
Expand All @@ -39,63 +48,39 @@ export async function init(messaging, telemetry, baseEnvironment) {
.withTextLength(baseEnvironment.urlParams.get('textLength'))
.withDisplay(baseEnvironment.urlParams.get('display'));

const strings =
environment.locale === 'en'
? enStrings
: await fetch(`./locales/${environment.locale}/new-tab.json`)
.then((x) => x.json())
.catch((e) => {
console.error('Could not load locale', environment.locale, e);
return enStrings;
});
// read the translation file
const strings = await getStrings(environment);

// create app-specific settings
const settings = new Settings({})
.withPlatformName(baseEnvironment.injectName)
.withPlatformName(init.platform?.name)
.withPlatformName(baseEnvironment.urlParams.get('platform'));

console.log('environment:', environment);
console.log('settings:', settings);
console.log('locale:', environment.locale);
if (!window.__playwright_01) {
console.log('environment:', environment);
console.log('settings:', settings);
console.log('locale:', environment.locale);
}

const didCatch = (error) => {
const message = error?.message || error?.error || 'unknown';
messaging.reportPageException({ message });
};

const root = document.querySelector('#app');
if (!root) throw new Error('could not render, root element missing');

document.body.dataset.platformName = settings.platform.name;

// return early if we're in the 'components' view.
if (environment.display === 'components') {
document.body.dataset.display = 'components';
return render(
<EnvironmentProvider debugState={environment.debugState} injectName={environment.injectName} willThrow={environment.willThrow}>
<SettingsProvider settings={settings}>
<TranslationProvider translationObject={strings} fallback={strings} textLength={environment.textLength}>
<Components />
</TranslationProvider>
</SettingsProvider>
</EnvironmentProvider>,
root,
);
return renderComponents(root, environment, settings, strings);
}

const entryPoints = await (async () => {
try {
const loaders = init.widgets.map((widget) => {
return widgetEntryPoint(widget.id).then((mod) => [widget.id, mod]);
});
const entryPoints = await Promise.all(loaders);
return Object.fromEntries(entryPoints);
} catch (e) {
const error = new Error('Error loading widget entry points:' + e.message);
didCatch(error);
console.error(error);
return {};
}
})();
// install global side effects that are not specific to any widget
installGlobalSideEffects(environment, settings);

// Resolve the entry points for each selected widget
const entryPoints = await resolveEntryPoints(init.widgets, didCatch);

// Create an instance of the global widget api
const widgetConfigAPI = new WidgetConfigService(messaging, init.widgetConfigs);

render(
<EnvironmentProvider
Expand Down Expand Up @@ -129,3 +114,70 @@ export async function init(messaging, telemetry, baseEnvironment) {
root,
);
}

/**
* @param {Environment} environment
*/
async function getStrings(environment) {
return environment.locale === 'en'
? enStrings
: await fetch(`./locales/${environment.locale}/new-tab.json`)
.then((x) => x.json())
.catch((e) => {
console.error('Could not load locale', environment.locale, e);
return enStrings;
});
}

/**
* @param {Environment} environment
* @param {Settings} settings
*/
function installGlobalSideEffects(environment, settings) {
document.body.dataset.platformName = settings.platform.name;
document.body.dataset.display = environment.display;
}

/**
*
* @param {import('../../../types/new-tab.js').InitialSetupResponse['widgets']} widgets
* @param {(e: {message:string}) => void} didCatch
* @return {Promise<{[p: string]: any}|{}>}
*/
async function resolveEntryPoints(widgets, didCatch) {
try {
const loaders = widgets.map((widget) => {
return (
widgetEntryPoint(widget.id)
// eslint-disable-next-line promise/prefer-await-to-then
.then((mod) => [widget.id, mod])
);
});
const entryPoints = await Promise.all(loaders);
return Object.fromEntries(entryPoints);
} catch (e) {
const error = new Error('Error loading widget entry points:' + e.message);
didCatch(error);
console.error(error);
return {};
}
}

/**
* @param {Element} root
* @param {Environment} environment
* @param {Settings} settings
* @param {Record<string, any>} strings
*/
function renderComponents(root, environment, settings, strings) {
render(
<EnvironmentProvider debugState={environment.debugState} injectName={environment.injectName} willThrow={environment.willThrow}>
<SettingsProvider settings={settings}>
<TranslationProvider translationObject={strings} fallback={strings} textLength={environment.textLength}>
<Components />
</TranslationProvider>
</SettingsProvider>
</EnvironmentProvider>,
root,
);
}
Loading

0 comments on commit ae441a9

Please sign in to comment.