TUNA April fools 写了一段小脚本来扔番茄。使用 Tempermonkey 或者类似的脚本注入插件应该可以让任何页面都可以扔番茄。Alternatively, 直接在 Devtools 里面执行,不需要额外加载任何跨域的东西,或者修改 HTML / CSS。

点我扔番茄

注:这个实现会 Break 掉部分使用 preventsDefault 的网站,比如这个博客本身,原因不太清楚。使用 dispatchEvent 触发的点击行为一直就不太一致。

注2:这个实现使用了 stopImmediatePropagation() 来尝试禁用所有其他点击的 event handler,并且添加在 window 的 capture 阶段。不过这还是依赖 handler 的添加顺序,例如 jQuery (in turn, Bootstrap) 没有用 DOM 自己的 event propagation,所以它的事件也是一个挂在 window 的 capture 阶段的 handler 处理的,这样这个脚本必须先于 jQuery 的初始化注册 handler,否则例如 Bootstrap 的下拉框、Modal 之类的点击行为不会被撤销掉。

注3:实现中控制参数存入 localStorage 的部分被注释掉了,如果有需求可以启用。另一方面,控制参数的 UI 背景是用 CSS Variable --color-bg 控制的,请按需修改。

const LIFE = 1;
let INIT_VY = -100;
let GRAVITY = 400;
let VARIANCE = 100;
let TOMATO = '🍅';
const POV = 0.5;

const tomatos = new Set()// { div, x, y, spawn, lastUpdate, vy, vx }
let cnt = 0;

function renderTomato(now, tomato) {
  const zoom = POV / (POV + (now - tomato.spawn) / 1000);
  tomato.div.style.transform = `translate(${tomato.x}px, ${tomato.y}px) scale(${zoom})`;
}

function updateTomato(now, tomato) {
  let dt = (now - tomato.lastUpdate) / 1000;
  let dying = (now - tomato.spawn) / 1000 >= LIFE;
  if(dying) dt = LIFE - (tomato.lastUpdate - tomato.spawn) / 1000;

  tomato.lastUpdate = now;

  tomato.x += tomato.vx * dt;
  tomato.y += tomato.vy * dt + 1/2 * GRAVITY * dt * dt;
  tomato.vy += GRAVITY * dt;

  return dying;
}

function dropTomato(tomato) {
  tomatos.delete(tomato);

  tomato.div.innerHTML = `<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 39.69563762 38.43225379"><path d="M18.98199913,4.97441789c1.56389432,4.80745789-.46066328,7.62163229-2.44002861,8.29263583-2.53598286.8596961-4.29761578-4.38429468-9.15011136-3.87008242-1.84382446.19538768-3.83614081,1.67551244-3.96504717,3.20253725-.24510055,2.90345579,6.40775611,4.06600515,6.86258216,8.46385293.15587863,1.50723321-.4478001,3.08938046-1.37251658,4.1175483-2.50284238,2.78284525-6.6955892.77141179-8.31134895,3.05003683-1.27961983,1.80458492-.39350903,5.5250663,1.37251586,6.25257408,2.57504129,1.0607807,5.40751027-4.90627186,10.75137913-4.49880362,1.85716764.14160855,3.26719114.99590292,3.88879768,1.37251515,3.72767703,2.25849263,3.34935929,5.41581108,5.9475704,6.6338294,3.32293532,1.55776247,8.75531949-1.3500013,8.76885518-3.4312893.01447686-2.22604518-2.3623387-3.59522382-3.12628675-7.72005619-.08354425-.45108135-.02054675-1.91027265.68625821-2.97378559,2.20132386-3.31228302,5.92067206,2.11272658,9.30260999-1.35380229,1.78983712-1.83460815,1.98686044-5.52309556.53375573-6.93883308-2.24108389-2.18345025-4.10409597,3.31455119-8.4638493.59129322-.49912935-.31177438-2.17815591-2.27556367-2.51628011-4.04129836-.73223358-3.82383719,2.45043615-6.64531075,1.83002176-9.43639977-.73251878-3.29542044-9.33401606-3.47168043-10.67512761-1.00997524-.53308266.97851059-.83545238.49490025.07625032,3.29750287Z" fill="#ed2024"/><path d="M23.72192942,12.5577001c.51093442,2.75402331-1.44313217,4.30434921-3.70591707,5.2953825-1.28405586,4.90638674,1.54601253,7.48536382,3.98242138,11.20577448,1.25273644-6.92586461-2.39798413-6.51376096,6.72470957-5.88299674,3.45361313-4.01403833-5.1613199-2.7864131-7.52321941-3.30795619.38946259-2.19197062,2.66072592-5.89503736,1.06679418-7.83927725" fill="#51b848"/></svg>`

  tomato.div.classList.add('splash');
  const vpx = tomato.x - window.visualViewport.pageLeft;
  const vpy = tomato.y - window.visualViewport.pageTop;
  const stack = document.elementsFromPoint(vpx, vpy);
  const field = document.getElementById('field');
  const ctrl = document.getElementsByClassName('field-ctrl')[0];
  for(const el of stack) {
    // Skip tomato
    if(field && field.contains(el)) continue;
    if(ctrl && ctrl.contains(el)) continue;
    try {
      if(el.computedStyleMap().get('pointer-events').toString() === 'none') continue;
    } catch(e) {
      console.log('Firefox?', el);
    }
    console.log(el);
    return el;
  }

  return null;
}

console.log('🍅 registered');

document.addEventListener('click', function(event) {
  const ctrl = document.getElementsByClassName('field-ctrl')[0];
  if(event.pointerType === 'synthetic') return;
  if(ctrl && ctrl.contains(event.target)) return;
  console.log('clicked at ', event.pageX, event.pageY);
  event.preventDefault();
  event.stopPropagation();
  event.stopImmediatePropagation();

  const div = document.createElement('div');
  const inner = document.createElement('div');
  inner.innerText = TOMATO;
  inner.className = 'tomato-inner';
  div.appendChild(inner);
  div.className = 'tomato';

  const now = document.timeline.currentTime;
  const tomato = {
    div,
    x: event.pageX,
    y: event.pageY,
    spawn: now,
    lastUpdate: now,
    vy: INIT_VY + (Math.random() - 1/2) * VARIANCE,
    vx: (Math.random() - 1/2) * VARIANCE,
  };
  renderTomato(now, tomato);

  const field = document.getElementById('field');
  field.appendChild(div);
  tomatos.add(tomato);
  cnt += 1;
  if(cnt === 10) {
    const ctrl = document.getElementsByClassName('field-ctrl')[0];
    // window.localStorage.setItem('tomato-hint', 'true');
    if(ctrl.classList.contains('tucked')) {
      ctrl.classList.remove('tucked');
      ctrl.classList.add('hidden');
    }
  }

}, {
  capture: true,
});

function loop(ts) {
  const clickTargets = [];
  for (const tomato of tomatos) {
    const dying = updateTomato(ts, tomato);
    renderTomato(ts, tomato);
    if(dying) {
      const target = dropTomato(tomato);
      if(target) clickTargets.push(target);
    }
  }

  if(clickTargets.length > 0) {
    for (const target of clickTargets) {
      const event = new PointerEvent('click', {
        bubbles: true,
        pointerType: 'synthetic',
      });
      target.dispatchEvent(event);
    }
  }

  requestAnimationFrame(loop);
}

requestAnimationFrame(loop);

function setData(key, value) {
  const input = document.getElementById(`field-${key}`);
  input.value = value;
  const ev = new Event('input', {
    bubbles: true,
    cancelable: true,
  });
  input.dispatchEvent(ev);
}

function lazerSettings() {
  setData('variance', 0);
  setData('gravity', 0);
  setData('init-vy', 0);
}

function defaultSettings() {
  setData('variance', 100);
  setData('gravity', 400);
  setData('init-vy', -100);
}

// HTML
const wrapper = document.createElement('div');
wrapper.innerHTML = `
<div id="field"></div>
<div class="field-ctrl tucked">
    <div class="field-ctrl-toggle">
        🍅 Got stuck?
    </div>

    <div class="field-ctrl-main">
        <div class="field-ctrl-info">
            <span>
                Aim for your target!
            </span>
            <button id="field-ctrl-lazer">Turn into lazer</button>
            <button id="field-ctrl-default">Deafult</button>
        </div>
        <div>
            <input id="field-variance" type="range" min="0" max="100" value="100" >
            <label for="field-variance">Variance: 100</label>
        </div>
        <div>
            <input id="field-gravity" type="range" min="0" max="1000" value="400" >
            <label for="field-gravity">Gravity: 400</label>
        </div>
        <div>
            <input id="field-init-vy" type="range" min="-200" max="200" value="-100" >
            <label for="field-init-vy">Initial Velocity: -100</label>
        </div>
    </div>
</div>
<style>
#field {
  position: absolute;
  z-index: 100000;
  pointer-events: none;
}

.tomato {
  width: 40px;
  height: 40px;
  position: absolute;
  top: -20px;
  left: -20px;
  transform-origin: 50% 50%;
}

.tomato-inner {
  width: 40px;
  height: 40px;

  animation: tomato-rotate .5s linear infinite;

  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 40px;
}

@keyframes tomato-rotate {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}

.tomato.splash svg {
  transform-origin: 50% 50%;
  transform: scale(3);
}

.field-ctrl {
  position: fixed;
  z-index: 100005;
  bottom: 0;
  left: 0;
  right: 0;
  padding: 0 20px 20px 20px;
  transition: transform .2s ease;
}

.field-ctrl-toggle {
  margin-bottom: 20px;
  padding: 0 20px;
  height: 40px;
  line-height: 40px;
  max-width: 200px;
  background: var(--color-bg);
  box-shadow: rgba(0,0,0, .3) 0 4px 6px;
  font-weight: bold;
  cursor: pointer;
  border-radius: 4px;
}

.field-ctrl-main {
  padding: 10px 20px;
  background: var(--color-bg);
  box-shadow: rgba(0,0,0, .3) 0 4px 6px;
  border-radius: 4px;

  & > div {
    display: flex;
    align-items: center;

    & > input {
      flex: 1;
    }

    & > label {
      margin-left: 10px;
    }
  }
}


.field-ctrl.tucked {
  transform: translateY(calc(100% + 20px));
}

.field-ctrl.hidden {
  transform: translateY(calc(100% - 50px));
}

.field-ctrl-info {
  display: flex;
  align-items: center;
  & > span {
    flex: 1;
  }
  & button {
    margin-left: 10px;
  }
}
</style>
`;

function setup() {
  // Inject HTML
  document.body.prepend(wrapper);

  const toggle = document.getElementsByClassName('field-ctrl-toggle')[0];
  toggle.addEventListener('click', function() {
    const ctrl = document.getElementsByClassName('field-ctrl')[0];
    ctrl.classList.toggle('hidden');
  });

  const defaultBtn = document.getElementById('field-ctrl-default');
  defaultBtn.addEventListener('click', function() {
    defaultSettings();
  });

  const lazer = document.getElementById('field-ctrl-lazer');
  lazer.addEventListener('click', function() {
    lazerSettings();
  });

  const variance = document.getElementById('field-variance');
  variance.addEventListener('input', function() {
    VARIANCE = parseInt(variance.value);
    const varianceLabel = document.querySelector('label[for="field-variance"]');
    varianceLabel.innerText = `Variance: ${VARIANCE}`;
    // window.localStorage.setItem('tomato-variance', VARIANCE);
  });

  const gravity = document.getElementById('field-gravity');
  gravity.addEventListener('input', function() {
    console.log('Update gravity: ', gravity.value);
    GRAVITY = parseInt(gravity.value);
    const gravityLabel = document.querySelector('label[for="field-gravity"]');
    gravityLabel.innerText = `Gravity: ${GRAVITY}`;
    // window.localStorage.setItem('tomato-gravity', GRAVITY);
  });

  const init_vy = document.getElementById('field-init-vy');
  init_vy.addEventListener('input', function() {
    INIT_VY = parseInt(init_vy.value);
    const initVyLabel = document.querySelector('label[for="field-init-vy"]');
    initVyLabel.innerText = `Initial Velocity: ${INIT_VY}`;
    // window.localStorage.setItem('tomato-init-vy', INIT_VY);
  });

  // Recover stored settings
  // setData('variance', parseInt(window.localStorage.getItem('tomato-variance') ?? 100));
  // setData('gravity', parseInt(window.localStorage.getItem('tomato-gravity') ?? 400));
  // setData('init-vy', parseInt(window.localStorage.getItem('tomato-init-vy') ?? -100));
  // if(window.localStorage.getItem('tomato-hint') === 'true') {
  //   const ctrl = document.getElementsByClassName('field-ctrl')[0];
  //   ctrl.classList.remove('tucked');
  //   ctrl.classList.add('hidden');
  // }
};

if(document.readyState !== 'loading') setup();
else document.addEventListener('DOMContentLoaded', () => setup());