Skip to content

Commit ae441a9

Browse files
shakyShaneShane Osbourne
andauthored
ntp: handle drag + drop outside of favorites (#1256)
* ntp: handle drag + drop outside of favorites * don't prevent drop in playwright tests * linting * fixed format --------- Co-authored-by: Shane Osbourne <[email protected]>
1 parent c9f7798 commit ae441a9

File tree

10 files changed

+274
-58
lines changed

10 files changed

+274
-58
lines changed

special-pages/index.mjs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,15 @@ for (const buildJob of buildJobs) {
133133
if (DEBUG) console.log('\t- import.meta.env: ', NODE_ENV);
134134
if (DEBUG) console.log('\t- import.meta.injectName: ', buildJob.injectName);
135135
if (!DRY_RUN) {
136-
buildSync({
136+
const output = buildSync({
137137
entryPoints: buildJob.entryPoints,
138138
outdir: buildJob.outputDir,
139139
bundle: true,
140-
format: 'iife',
140+
// metafile: true,
141+
// minify: true,
142+
// splitting: true,
141143
// external: ['../assets/img/*'],
144+
format: 'iife',
142145
sourcemap: NODE_ENV === 'development',
143146
loader: {
144147
'.js': 'jsx',
@@ -156,6 +159,10 @@ for (const buildJob of buildJobs) {
156159
},
157160
dropLabels: buildJob.injectName === 'integration' ? [] : ['$INTEGRATION'],
158161
});
162+
if (output.metafile) {
163+
const meta = JSON.stringify(output.metafile, null, 2);
164+
writeFileSync(join(buildJob.outputDir, 'metafile.json'), meta);
165+
}
159166
}
160167
}
161168
for (const inlineJob of inlineJobs) {

special-pages/pages/new-tab/app/components/App.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { h } from 'preact';
22
import styles from './App.module.css';
33
import { usePlatformName } from '../settings.provider.js';
44
import { WidgetList } from '../widget-list/WidgetList.js';
5+
import { useGlobalDropzone } from '../dropzone.js';
56

67
/**
78
* Renders the App component.
@@ -11,6 +12,7 @@ import { WidgetList } from '../widget-list/WidgetList.js';
1112
*/
1213
export function App({ children }) {
1314
const platformName = usePlatformName();
15+
useGlobalDropzone();
1416
return (
1517
<div className={styles.layout} data-platform={platformName}>
1618
<WidgetList />
Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
import { h } from 'preact';
22

3-
export function Centered({ children }) {
4-
return <div class="layout-centered">{children}</div>;
3+
/**
4+
* @param {object} props
5+
* @param {import("preact").ComponentChild} props.children
6+
* @param {import("preact").ComponentProps<"div">} [props.rest]
7+
*/
8+
export function Centered({ children, ...rest }) {
9+
return (
10+
<div {...rest} class="layout-centered">
11+
{children}
12+
</div>
13+
);
514
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { useEffect, useRef } from 'preact/hooks';
2+
3+
const REGISTER_EVENT = 'register-dropzone';
4+
const CLEAR_EVENT = 'clear-dropzone';
5+
6+
/**
7+
* Setup the global listeners for the page. This prevents a new tab
8+
* being launched when an unsupported link is dropped in (like from another app)
9+
*/
10+
export function useGlobalDropzone() {
11+
useEffect(() => {
12+
/** @type {HTMLElement[]} */
13+
let safezones = [];
14+
const controller = new AbortController();
15+
16+
/**
17+
* Allow HTML elements to be part of a 'safe zone' where dropping is allowed
18+
*/
19+
window.addEventListener(
20+
REGISTER_EVENT,
21+
(/** @type {CustomEvent} */ e) => {
22+
if (isValidEvent(e)) {
23+
safezones.push(e.detail.dropzone);
24+
}
25+
},
26+
{ signal: controller.signal },
27+
);
28+
29+
/**
30+
* Allow registered HTML elements to be removed from the list of safe-zones
31+
*/
32+
window.addEventListener(
33+
CLEAR_EVENT,
34+
(/** @type {CustomEvent} */ e) => {
35+
if (isValidEvent(e)) {
36+
const match = safezones.findIndex((x) => x === e.detail.dropzone);
37+
safezones.splice(match, 1);
38+
}
39+
},
40+
{ signal: controller.signal },
41+
);
42+
43+
/**
44+
* Use drag over to ensure
45+
*/
46+
document.addEventListener(
47+
'dragover',
48+
(event) => {
49+
if (!event.target) return;
50+
const target = /** @type {HTMLElement} */ (event.target);
51+
if (safezones.length > 0) {
52+
for (const safezone of safezones) {
53+
if (safezone.contains(target)) return;
54+
}
55+
}
56+
57+
// At the moment, this is not supported in the Playwright tests :(
58+
// So we allow the integration build to check first
59+
let preventDrop = true;
60+
// eslint-disable-next-line no-labels,no-unused-labels
61+
$INTEGRATION: (() => {
62+
if (window.__playwright_01) {
63+
preventDrop = false;
64+
}
65+
})();
66+
67+
if (preventDrop) {
68+
// if we get here, we're stopping a drag/drop from being allowed
69+
event.preventDefault();
70+
if (event.dataTransfer) {
71+
event.dataTransfer.dropEffect = 'none';
72+
}
73+
}
74+
},
75+
{ signal: controller.signal },
76+
);
77+
return () => {
78+
controller.abort();
79+
safezones = [];
80+
};
81+
}, []);
82+
}
83+
84+
/**
85+
* Register an area allowed to receive drop events
86+
*/
87+
export function useDropzoneSafeArea() {
88+
const ref = useRef(null);
89+
useEffect(() => {
90+
if (!ref.current) return;
91+
const evt = new CustomEvent(REGISTER_EVENT, { detail: { dropzone: ref.current } });
92+
window.dispatchEvent(evt);
93+
return () => {
94+
window.dispatchEvent(new CustomEvent(CLEAR_EVENT, { detail: { dropzone: ref.current } }));
95+
};
96+
}, []);
97+
return ref;
98+
}
99+
100+
/**
101+
* @param {Record<string, any>} input
102+
* @return {input is {dropzone: HTMLElement}}
103+
*/
104+
function isValidEvent(input) {
105+
return 'detail' in input && input.detail.dropzone instanceof HTMLElement;
106+
}

special-pages/pages/new-tab/app/entry-points/favorites.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { FavoritesCustomized } from '../favorites/components/FavoritesCustomized
44

55
export function factory() {
66
return (
7-
<Centered>
7+
<Centered data-entry-point="favorites">
88
<FavoritesCustomized />
99
</Centered>
1010
);

special-pages/pages/new-tab/app/favorites/components/Favorites.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Placeholder, PlusIconMemo, TileMemo } from './Tile.js';
88
import { ShowHideButton } from '../../components/ShowHideButton.jsx';
99
import { useTypedTranslation } from '../../types.js';
1010
import { usePlatformName } from '../../settings.provider.js';
11+
import { useDropzoneSafeArea } from '../../dropzone.js';
1112

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

3537
const ROW_CAPACITY = 6;
3638

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

110112
return (
111113
<div class={cn(styles.root, !canToggleExpansion && styles.bottomSpace)} data-testid="FavoritesConfigured">
112-
<div class={styles.grid} id={WIDGET_ID} ref={gridRef} onContextMenu={onContextMenu} onClick={onClick}>
114+
<div class={styles.grid} id={WIDGET_ID} ref={safeArea} onContextMenu={onContextMenu} onClick={onClick}>
113115
{items.slice(0, expansion === 'expanded' ? undefined : ROW_CAPACITY)}
114116
</div>
115117
{canToggleExpansion && (

special-pages/pages/new-tab/app/index.js

Lines changed: 100 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,26 @@ import { WidgetConfigProvider } from './widget-list/widget-config.provider.js';
1212
import { Settings } from './settings.js';
1313
import { Components } from './components/Components.jsx';
1414
import { widgetEntryPoint } from './widget-list/WidgetList.js';
15+
import { callWithRetry } from '../../../shared/call-with-retry.js';
1516

1617
/**
18+
* @import {Telemetry} from "./telemetry/telemetry.js"
19+
* @import { Environment } from "../../../shared/environment";
20+
* @param {Element} root
1721
* @param {import("../src/js").NewTabPage} messaging
1822
* @param {import("./telemetry/telemetry.js").Telemetry} telemetry
19-
* @param {import("../../../shared/environment").Environment} baseEnvironment
23+
* @param {Environment} baseEnvironment
24+
* @throws Error
2025
*/
21-
export async function init(messaging, telemetry, baseEnvironment) {
22-
const init = await messaging.init();
26+
export async function init(root, messaging, telemetry, baseEnvironment) {
27+
const result = await callWithRetry(() => messaging.init());
28+
29+
// handle fatal exceptions, the following things prevent anything from starting.
30+
if ('error' in result) {
31+
throw new Error(result.error);
32+
}
33+
34+
const init = result.value;
2335

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

31-
// Create an instance of the global widget api
32-
const widgetConfigAPI = new WidgetConfigService(messaging, init.widgetConfigs);
33-
3443
// update the 'env' in case it was changed by native sides
3544
const environment = baseEnvironment
3645
.withEnv(init.env)
@@ -39,63 +48,39 @@ export async function init(messaging, telemetry, baseEnvironment) {
3948
.withTextLength(baseEnvironment.urlParams.get('textLength'))
4049
.withDisplay(baseEnvironment.urlParams.get('display'));
4150

42-
const strings =
43-
environment.locale === 'en'
44-
? enStrings
45-
: await fetch(`./locales/${environment.locale}/new-tab.json`)
46-
.then((x) => x.json())
47-
.catch((e) => {
48-
console.error('Could not load locale', environment.locale, e);
49-
return enStrings;
50-
});
51+
// read the translation file
52+
const strings = await getStrings(environment);
5153

54+
// create app-specific settings
5255
const settings = new Settings({})
5356
.withPlatformName(baseEnvironment.injectName)
5457
.withPlatformName(init.platform?.name)
5558
.withPlatformName(baseEnvironment.urlParams.get('platform'));
5659

57-
console.log('environment:', environment);
58-
console.log('settings:', settings);
59-
console.log('locale:', environment.locale);
60+
if (!window.__playwright_01) {
61+
console.log('environment:', environment);
62+
console.log('settings:', settings);
63+
console.log('locale:', environment.locale);
64+
}
6065

6166
const didCatch = (error) => {
6267
const message = error?.message || error?.error || 'unknown';
6368
messaging.reportPageException({ message });
6469
};
6570

66-
const root = document.querySelector('#app');
67-
if (!root) throw new Error('could not render, root element missing');
68-
69-
document.body.dataset.platformName = settings.platform.name;
70-
71+
// return early if we're in the 'components' view.
7172
if (environment.display === 'components') {
72-
document.body.dataset.display = 'components';
73-
return render(
74-
<EnvironmentProvider debugState={environment.debugState} injectName={environment.injectName} willThrow={environment.willThrow}>
75-
<SettingsProvider settings={settings}>
76-
<TranslationProvider translationObject={strings} fallback={strings} textLength={environment.textLength}>
77-
<Components />
78-
</TranslationProvider>
79-
</SettingsProvider>
80-
</EnvironmentProvider>,
81-
root,
82-
);
73+
return renderComponents(root, environment, settings, strings);
8374
}
8475

85-
const entryPoints = await (async () => {
86-
try {
87-
const loaders = init.widgets.map((widget) => {
88-
return widgetEntryPoint(widget.id).then((mod) => [widget.id, mod]);
89-
});
90-
const entryPoints = await Promise.all(loaders);
91-
return Object.fromEntries(entryPoints);
92-
} catch (e) {
93-
const error = new Error('Error loading widget entry points:' + e.message);
94-
didCatch(error);
95-
console.error(error);
96-
return {};
97-
}
98-
})();
76+
// install global side effects that are not specific to any widget
77+
installGlobalSideEffects(environment, settings);
78+
79+
// Resolve the entry points for each selected widget
80+
const entryPoints = await resolveEntryPoints(init.widgets, didCatch);
81+
82+
// Create an instance of the global widget api
83+
const widgetConfigAPI = new WidgetConfigService(messaging, init.widgetConfigs);
9984

10085
render(
10186
<EnvironmentProvider
@@ -129,3 +114,70 @@ export async function init(messaging, telemetry, baseEnvironment) {
129114
root,
130115
);
131116
}
117+
118+
/**
119+
* @param {Environment} environment
120+
*/
121+
async function getStrings(environment) {
122+
return environment.locale === 'en'
123+
? enStrings
124+
: await fetch(`./locales/${environment.locale}/new-tab.json`)
125+
.then((x) => x.json())
126+
.catch((e) => {
127+
console.error('Could not load locale', environment.locale, e);
128+
return enStrings;
129+
});
130+
}
131+
132+
/**
133+
* @param {Environment} environment
134+
* @param {Settings} settings
135+
*/
136+
function installGlobalSideEffects(environment, settings) {
137+
document.body.dataset.platformName = settings.platform.name;
138+
document.body.dataset.display = environment.display;
139+
}
140+
141+
/**
142+
*
143+
* @param {import('../../../types/new-tab.js').InitialSetupResponse['widgets']} widgets
144+
* @param {(e: {message:string}) => void} didCatch
145+
* @return {Promise<{[p: string]: any}|{}>}
146+
*/
147+
async function resolveEntryPoints(widgets, didCatch) {
148+
try {
149+
const loaders = widgets.map((widget) => {
150+
return (
151+
widgetEntryPoint(widget.id)
152+
// eslint-disable-next-line promise/prefer-await-to-then
153+
.then((mod) => [widget.id, mod])
154+
);
155+
});
156+
const entryPoints = await Promise.all(loaders);
157+
return Object.fromEntries(entryPoints);
158+
} catch (e) {
159+
const error = new Error('Error loading widget entry points:' + e.message);
160+
didCatch(error);
161+
console.error(error);
162+
return {};
163+
}
164+
}
165+
166+
/**
167+
* @param {Element} root
168+
* @param {Environment} environment
169+
* @param {Settings} settings
170+
* @param {Record<string, any>} strings
171+
*/
172+
function renderComponents(root, environment, settings, strings) {
173+
render(
174+
<EnvironmentProvider debugState={environment.debugState} injectName={environment.injectName} willThrow={environment.willThrow}>
175+
<SettingsProvider settings={settings}>
176+
<TranslationProvider translationObject={strings} fallback={strings} textLength={environment.textLength}>
177+
<Components />
178+
</TranslationProvider>
179+
</SettingsProvider>
180+
</EnvironmentProvider>,
181+
root,
182+
);
183+
}

0 commit comments

Comments
 (0)