Excalidraw и Fugu: улучшение взаимодействия основных пользователей

Любая достаточно развитая технология неотличима от магии. Если только ты этого не понимаешь. Меня зовут Томас Штайнер, я работаю в отделе по связям с разработчиками в Google, и в этой записи моего доклада о Google I/O я рассмотрю некоторые новые API-интерфейсы Fugu и то, как они улучшают работу основных пользователей в Excalidraw PWA, поэтому вы можете вдохновиться этими идеями и применить их в своих приложениях.

Как я пришел в Excalidraw

Я хочу начать с истории. 1 января 2020 года Кристофер Шедо , инженер-программист Facebook, написал в Твиттере о небольшом приложении для рисования, над которым он начал работать. С помощью этого инструмента вы можете рисовать прямоугольники и стрелки, которые кажутся мультяшными и нарисованными от руки. На следующий день вы также можете рисовать эллипсы и текст, а также выбирать объекты и перемещать их. 3 января приложение получило название Excalidraw, и, как и в случае с любым хорошим побочным проектом, покупка доменного имени была одним из первых действий Кристофера. Теперь вы можете использовать цвета и экспортировать весь рисунок в формате PNG.

Снимок экрана прототипа приложения Excalidraw, показывающий, что оно поддерживает прямоугольники, стрелки, эллипсы и текст.

15 января Кристофер опубликовал сообщение в блоге , которое привлекло большое внимание в Твиттере, в том числе и мое. Пост начался с впечатляющей статистики:

  • 12 тысяч уникальных активных пользователей
  • 1,5 тыс. звезд на GitHub
  • 26 участников

Для проекта, стартовавшего всего две недели назад, это совсем неплохо. Но то, что действительно пробудило мой интерес, было дальше в посте. Кристофер написал, что на этот раз он попробовал что-то новое: предоставил каждому, кто получил запрос на включение, безусловный доступ к фиксации. В тот же день, когда я прочитал сообщение в блоге, у меня появился запрос на включение , который добавил поддержку API доступа к файловой системе в Excalidraw, исправив поданный кем-то запрос на функцию .

Скриншот твита, где я объявляю свой пиар.

Мой запрос на включение был объединен через день, и с этого момента у меня был полный доступ к фиксации. Излишне говорить, что я не злоупотреблял своей властью. И никто из 149 участников до сих пор этого не сделал.

Сегодня Excalidraw — это полноценное устанавливаемое прогрессивное веб-приложение с автономной поддержкой, потрясающим темным режимом и, да, возможностью открывать и сохранять файлы благодаря API доступа к файловой системе.

Скриншот Excalidraw PWA в сегодняшнем состоянии.

Липис о том, почему он так много времени уделяет Excalidraw

На этом моя история о том, как я пришел к Excalidraw, подходит к концу, но прежде чем я углублюсь в некоторые удивительные возможности Excalidraw, я имею удовольствие представить вам Панайотиса. Панайотис Липиридис, известный в Интернете просто как Lipis , является наиболее активным участником Excalidraw. Я спросил Липиса, что мотивирует его посвящать Excalidraw так много времени:

Как и все остальные, я узнал об этом проекте из твита Кристофера. Моим первым вкладом было добавление библиотеки Open Color — цветов, которые до сих пор являются частью Excalidraw. Поскольку проект разросся и у нас появилось довольно много запросов, моим следующим большим вкладом стало создание серверной части для хранения рисунков, чтобы пользователи могли ими делиться. Но что действительно заставляет меня внести свой вклад, так это то, что тот, кто попробовал Excalidraw, ищет оправдания, чтобы использовать его снова.

Полностью согласен с Липисом. Тот, кто пробовал Excalidraw, ищет оправдания, чтобы использовать его снова.

Экскалидро в действии

Теперь я хочу показать вам, как вы можете использовать Excalidraw на практике. Я не великий художник, но логотип Google I/O достаточно прост, поэтому позвольте мне попробовать. Прямоугольник — это буква «i», линия — косая черта, а «о» — круг. Я удерживаю Shift и получаю идеальный круг. Позвольте мне немного сдвинуть косую черту, чтобы она выглядела лучше. Теперь немного цвета для «i» и «o». Синий — это хорошо. Может быть, другой стиль заливки? Все сплошное или штриховка? Не, штриховка выглядит великолепно. Он не идеален, но в этом и заключается идея Excalidraw, поэтому позвольте мне сохранить его.

Я нажимаю значок сохранения и ввожу имя файла в диалоговом окне сохранения файла. В Chrome, браузере, поддерживающем API доступа к файловой системе, это не загрузка, а настоящая операция сохранения, где я могу выбрать местоположение и имя файла, и где, если я вношу изменения, я могу просто сохранить их. в тот же файл.

Позвольте мне изменить логотип и сделать букву «i» красной. Если я снова нажму «Сохранить», моя модификация сохранится в тот же файл, что и раньше. В качестве доказательства позвольте мне очистить холст и снова открыть файл. Как видите, измененный красно-синий логотип снова здесь.

Работа с файлами

В браузерах, которые в настоящее время не поддерживают API доступа к файловой системе, каждая операция сохранения представляет собой загрузку, поэтому, когда я вношу изменения, я получаю несколько файлов с увеличивающимся номером в имени файла, которые заполняют мою папку «Загрузки». Но, несмотря на этот недостаток, я все равно могу сохранить файл.

Открытие файлов

Так в чем же секрет? Как открытие и сохранение могут работать в разных браузерах, которые могут поддерживать или не поддерживать API доступа к файловой системе? Открытие файла в Excalidraw происходит с помощью функции loadFromJSON)( ), которая, в свою очередь, вызывает функцию fileOpen() .

export const loadFromJSON = async (localAppState: AppState) => {
  const blob = await fileOpen({
    description: 'Excalidraw files',
    extensions: ['.json', '.excalidraw', '.png', '.svg'],
    mimeTypes: ['application/json', 'image/png', 'image/svg+xml'],
  });
  return loadFromBlob(blob, localAppState);
};

Функция fileOpen() взята из написанной мною небольшой библиотеки под названием Browser-fs-access , которую мы используем в Excalidraw. Эта библиотека обеспечивает доступ к файловой системе через API доступа к файловой системе с устаревшим резервным вариантом, поэтому ее можно использовать в любом браузере.

Позвольте мне сначала показать вам реализацию, когда поддерживается API. После согласования принятых типов MIME и расширений файлов центральная часть вызывает функцию API доступа к файловой системе showOpenFilePicker() . Эта функция возвращает массив файлов или один файл, в зависимости от того, выбрано ли несколько файлов. Все, что остается, — это поместить дескриптор файла в файловый объект, чтобы его можно было снова получить.

export default async (options = {}) => {
  const accept = {};
  // Not shown: deal with extensions and MIME types.
  const handleOrHandles = await window.showOpenFilePicker({
    types: [
      {
        description: options.description || '',
        accept: accept,
      },
    ],
    multiple: options.multiple || false,
  });
  const files = await Promise.all(handleOrHandles.map(getFileWithHandle));
  if (options.multiple) return files;
  return files[0];
  const getFileWithHandle = async (handle) => {
    const file = await handle.getFile();
    file.handle = handle;
    return file;
  };
};

Резервная реализация опирается на input элемент типа "file" . После согласования типов и расширений MIME, которые будут приняты, следующим шагом будет программный щелчок по элементу ввода, чтобы отобразилось диалоговое окно открытия файла. При изменении, то есть когда пользователь выбирает один или несколько файлов, обещание разрешается.

export default async (options = {}) => {
  return new Promise((resolve) => {
    const input = document.createElement('input');
    input.type = 'file';
    const accept = [
      ...(options.mimeTypes ? options.mimeTypes : []),
      options.extensions ? options.extensions : [],
    ].join();
    input.multiple = options.multiple || false;
    input.accept = accept || '*/*';
    input.addEventListener('change', () => {
      resolve(input.multiple ? Array.from(input.files) : input.files[0]);
    });
    input.click();
  });
};

Сохранение файлов

Теперь об экономии. В Excalidraw сохранение происходит с помощью функции saveAsJSON() . Сначала он сериализует массив элементов Excalidraw в JSON, преобразует JSON в большой двоичный объект, а затем вызывает функцию fileSave() . Эта функция также предоставляется библиотекой браузера-fs-access .

export const saveAsJSON = async (
  elements: readonly ExcalidrawElement[],
  appState: AppState,
) => {
  const serialized = serializeAsJSON(elements, appState);
  const blob = new Blob([serialized], {
    type: 'application/vnd.excalidraw+json',
  });
  const fileHandle = await fileSave(
    blob,
    {
      fileName: appState.name,
      description: 'Excalidraw file',
      extensions: ['.excalidraw'],
    },
    appState.fileHandle,
  );
  return { fileHandle };
};

Опять же, позвольте мне сначала рассмотреть реализацию для браузеров с поддержкой API доступа к файловой системе. Первые несколько строк выглядят немного запутанными, но все, что они делают, — это согласовывают типы MIME и расширения файлов. Если я уже сохранял файл и у меня уже есть дескриптор файла, диалоговое окно сохранения не требуется. Но если это первое сохранение, отображается диалоговое окно файла, и приложение возвращает дескриптор файла для дальнейшего использования. Остальное — это просто запись в файл, что происходит через записываемый поток .

export default async (blob, options = {}, handle = null) => {
  options.fileName = options.fileName || 'Untitled';
  const accept = {};
  // Not shown: deal with extensions and MIME types.
  handle =
    handle ||
    (await window.showSaveFilePicker({
      suggestedName: options.fileName,
      types: [
        {
          description: options.description || '',
          accept: accept,
        },
      ],
    }));
  const writable = await handle.createWritable();
  await writable.write(blob);
  await writable.close();
  return handle;
};

Функция «Сохранить как»

Если я решу игнорировать уже существующий дескриптор файла, я могу реализовать функцию «сохранить как», чтобы создать новый файл на основе существующего файла. Чтобы продемонстрировать это, позвольте мне открыть существующий файл, внести некоторые изменения, а затем не перезаписывать существующий файл, а создать новый файл, используя функцию «Сохранить как». Это оставляет исходный файл нетронутым.

Реализация для браузеров, которые не поддерживают API доступа к файловой системе, короткая, поскольку все, что она делает, — это создает элемент привязки с атрибутом download , значением которого является желаемое имя файла, и URL-адрес большого двоичного объекта в качестве значения атрибута href .

export default async (blob, options = {}) => {
  const a = document.createElement('a');
  a.download = options.fileName || 'Untitled';
  a.href = URL.createObjectURL(blob);
  a.addEventListener('click', () => {
    setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
  });
  a.click();
};

Затем элемент привязки щелкается программно. Чтобы предотвратить утечку памяти, URL-адрес большого двоичного объекта необходимо отозвать после использования. Поскольку это всего лишь загрузка, диалоговое окно сохранения файла не отображается, и все файлы попадают в папку Downloads по умолчанию.

Перетащите

Одна из моих любимых системных интеграций на рабочем столе — перетаскивание. В Excalidraw, когда я помещаю файл .excalidraw в приложение, он сразу открывается, и я могу начать редактирование. В браузерах, поддерживающих API доступа к файловой системе, я могу сразу же сохранить изменения. Нет необходимости проходить через диалоговое окно сохранения файла, поскольку необходимый дескриптор файла был получен в результате операции перетаскивания.

Секрет этого заключается в вызове getAsFileSystemHandle() для элемента передачи данных , когда поддерживается API доступа к файловой системе. Затем я передаю дескриптор этого файла в loadFromBlob() , которую вы, возможно, помните из пары абзацев выше. С файлами можно делать так много всего: открывать, сохранять, пересохранять, перетаскивать, удалять. Мы с моим коллегой Питом задокументировали все эти и другие приемы в нашей статье , чтобы вы могли наверстать упущенное, если все пойдет слишком быстро.

const file = event.dataTransfer?.files[0];
if (file?.type === 'application/json' || file?.name.endsWith('.excalidraw')) {
  this.setState({ isLoading: true });
  // Provided by browser-fs-access.
  if (supported) {
    try {
      const item = event.dataTransfer.items[0];
      file as any.handle = await item as any
        .getAsFileSystemHandle();
    } catch (error) {
      console.warn(error.name, error.message);
    }
  }
  loadFromBlob(file, this.state).then(({ elements, appState }) =>
    // Load from blob
  ).catch((error) => {
    this.setState({ isLoading: false, errorMessage: error.message });
  });
}

Обмен файлами

Еще одна системная интеграция в настоящее время в Android, ChromeOS и Windows осуществляется через Web Share Target API . Я нахожусь в приложении «Файлы» в папке Downloads . Я вижу два файла, один из них с неописуемым именем, untitled и меткой времени. Чтобы проверить, что он содержит, я нажимаю на три точки, затем делюсь, и один из появившихся вариантов — Excalidraw. Когда я нажимаю на значок, я вижу, что файл снова содержит только логотип ввода-вывода.

Липис об устаревшей версии Electron

С файлами можно сделать одну вещь, о которой я еще не говорил, — это дважды щелкнуть по ним. Обычно при двойном щелчке по файлу происходит открытие приложения, связанного с MIME-типом файла. Например, для .docx это будет Microsoft Word.

Раньше у Excalidraw была версия приложения Electron , которая поддерживала такие ассоциации типов файлов, поэтому при двойном щелчке по файлу .excalidraw открывалось приложение Excalidraw Electron. Липис, с которым вы уже встречались ранее, был одновременно создателем и противником Excalidraw Electron. Я спросил его, почему он считает возможным отказаться от версии Electron:

Люди с самого начала просили приложение Electron, главным образом потому, что хотели открывать файлы двойным щелчком мыши. Мы также намеревались разместить приложение в магазинах приложений. Параллельно кто-то предложил вместо этого создать PWA, поэтому мы просто сделали и то, и другое. К счастью, мы познакомились с API-интерфейсами Project Fugu, такими как доступ к файловой системе, доступ к буферу обмена, обработка файлов и многое другое. Одним щелчком мыши вы можете установить приложение на свой настольный компьютер или мобильный телефон без дополнительного веса Electron. Было легко принять решение отказаться от версии Electron, сосредоточиться только на веб-приложении и сделать ее лучшим PWA. Кроме того, теперь мы можем публиковать PWA в Play Store и Microsoft Store! Это огромно!

Можно сказать, что Excalidraw для Electron устарел не потому, что Electron плох, вовсе нет, а потому, что Интернет стал достаточно хорошим. Мне это нравится!

Обработка файлов

Когда я говорю, что «Интернет стал достаточно хорош», я имею в виду такие функции, как предстоящая функция обработки файлов.

Это обычная установка macOS Big Sur. Теперь посмотрите, что происходит, когда я щелкаю правой кнопкой мыши файл Excalidraw. Я могу открыть его с помощью Excalidraw, установленного PWA. Конечно, двойной щелчок тоже подойдет, просто его менее драматично демонстрировать в скринкасте.

Так как же это работает? Первый шаг — сделать типы файлов, которые может обрабатывать мое приложение, известными операционной системе. Я делаю это в новом поле file_handlers в манифесте веб-приложения. Его значением является массив объектов с действием и свойством accept . Действие определяет URL-адрес, по которому операционная система запускает ваше приложение, а объектом принятия являются пары ключ-значение типов MIME и связанных расширений файлов.

{
  "name": "Excalidraw",
  "description": "Excalidraw is a whiteboard tool...",
  "start_url": "/",
  "display": "standalone",
  "theme_color": "#000000",
  "background_color": "#ffffff",
  "file_handlers": [
    {
      "action": "/",
      "accept": {
        "application/vnd.excalidraw+json": [".excalidraw"]
      }
    }
  ]
}

Следующий шаг — обработка файла при запуске приложения. Это происходит в интерфейсе launchQueue , где мне нужно установить потребителя, вызвав setConsumer() . Параметр этой функции — асинхронная функция, которая получает launchParams . Этот объект launchParams имеет поле под названием files, которое предоставляет мне массив дескрипторов файлов для работы. Меня интересует только первый, и из этого дескриптора файла я получаю большой двоичный объект, который затем передаю нашему старому другу loadFromBlob() .

if ('launchQueue' in window && 'LaunchParams' in window) {
  window as any.launchQueue
    .setConsumer(async (launchParams: { files: any[] }) => {
      if (!launchParams.files.length) return;
      const fileHandle = launchParams.files[0];
      const blob: Blob = await fileHandle.getFile();
      blob.handle = fileHandle;
      loadFromBlob(blob, this.state).then(({ elements, appState }) =>
        // Initialize app state.
      ).catch((error) => {
        this.setState({ isLoading: false, errorMessage: error.message });
      });
    });
}

Опять же, если дело пошло слишком быстро, подробнее о File Handling API вы можете прочитать в моей статье . Вы можете включить обработку файлов, установив флаг экспериментальных функций веб-платформы. Планируется, что он появится в Chrome позднее в этом году.

Интеграция с буфером обмена

Еще одна интересная особенность Excalidraw — интеграция с буфером обмена. Я могу скопировать весь свой рисунок или только его части в буфер обмена, возможно, добавив водяной знак, если захочу, а затем вставить его в другое приложение. Кстати, это веб-версия приложения Paint для Windows 95.

То, как это работает, на удивление просто. Все, что мне нужно, это холст в виде большого двоичного объекта, который я затем записываю в буфер обмена, передавая одноэлементный массив с ClipboardItem с большим двоичным объектом в функцию navigator.clipboard.write() . Дополнительную информацию о том, что можно делать с помощью API буфера обмена, см. в нашей с Джейсоном статье .

export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
  const blob = await canvasToBlob(canvas);
  await navigator.clipboard.write([
    new window.ClipboardItem({
      'image/png': blob,
    }),
  ]);
};

export const canvasToBlob = async (canvas: HTMLCanvasElement): Promise<Blob> => {
  return new Promise((resolve, reject) => {
    try {
      canvas.toBlob((blob) => {
        if (!blob) {
          return reject(new CanvasError(t('canvasError.canvasTooBig'), 'CANVAS_POSSIBLY_TOO_BIG'));
        }
        resolve(blob);
      });
    } catch (error) {
      reject(error);
    }
  });
};

Сотрудничество с другими

Публикация URL-адреса сеанса

Знаете ли вы, что в Excalidraw также есть совместный режим? Разные люди могут работать вместе над одним и тем же документом. Чтобы начать новый сеанс, я нажимаю кнопку интерактивного сотрудничества, а затем начинаю сеанс. Я могу легко поделиться URL-адресом сеанса со своими коллегами благодаря API Web Share , интегрированному в Excalidraw.

Живое сотрудничество

Я смоделировал сеанс совместной работы локально, работая над логотипом Google I/O на своем Pixelbook, телефоне Pixel 3a и iPad Pro. Вы можете видеть, что изменения, которые я вношу на одном устройстве, отражаются на всех остальных устройствах.

Я даже вижу, как все курсоры движутся. Курсор Pixelbook движется стабильно, поскольку им управляет трекпад, но курсор телефона Pixel 3a и курсор планшета iPad Pro прыгают, поскольку я управляю этими устройствами, постукивая пальцем.

Просмотр статусов соавторов

Чтобы улучшить взаимодействие в реальном времени, существует даже система обнаружения простоя. Когда я использую курсор iPad Pro, он показывает зеленую точку. Точка становится черной, когда я переключаюсь на другую вкладку браузера или приложение. А когда я нахожусь в приложении Excalidraw, но ничего не делаю, курсор показывает, что я бездействую, что обозначается тремя zZZ.

Заядлые читатели наших публикаций могут подумать, что обнаружение простоя реализуется через API Idle Detection API — предложение на ранней стадии, над которым работали в контексте Project Fugu. Спойлер: это не так. Хотя у нас была реализация на основе этого API в Excalidraw, в конце концов мы решили пойти на более традиционный подход, основанный на измерении перемещения указателя и видимости страницы.

Снимок экрана с отзывом об обнаружении простоя, размещенным в репозитории WICG Idle Detection.

Мы отправили отзыв о том, почему API обнаружения простоя не решает имеющийся у нас вариант использования. Все API-интерфейсы Project Fugu разрабатываются открыто, поэтому каждый может принять участие и быть услышанным!

Липис о том, что сдерживает Excalidraw

Говоря об этом, я задал Липису последний вопрос о том, чего, по его мнению, не хватает в веб-платформе, которая сдерживает Excalidraw:

API доступа к файловой системе — это здорово, но знаете что? Большинство файлов, которые меня сейчас волнуют, хранятся в моем Dropbox или Google Drive, а не на жестком диске. Я бы хотел, чтобы API доступа к файловой системе включал в себя уровень абстракции, с которым могли бы интегрироваться поставщики удаленных файловых систем, такие как Dropbox или Google, и чтобы разработчики могли писать код. Тогда пользователи смогут расслабиться и быть уверенными, что их файлы в безопасности благодаря облачному провайдеру, которому они доверяют.

Полностью согласен с липисом, я тоже живу в облаке. Мы надеемся, что это будет реализовано в ближайшее время.

Режим приложения с вкладками

Ух ты! Мы видели много действительно хороших интеграций API в Excalidraw. Файловая система , обработка файлов , буфер обмена , общий веб-ресурс и целевой веб-ресурс . Но вот еще одна вещь. До сих пор я мог редактировать только один документ одновременно. Уже нет. Пожалуйста, впервые насладитесь ранней версией режима приложений с вкладками в Excalidraw. Вот как это выглядит.

У меня есть существующий файл, открытый в установленном Excalidraw PWA, который работает в автономном режиме. Теперь я открываю новую вкладку в отдельном окне. Это не обычная вкладка браузера, а вкладка PWA. На этой новой вкладке я могу открыть дополнительный файл и работать с ним независимо из того же окна приложения.

Режим приложений с вкладками находится на ранней стадии, и еще не все высечено на камне. Если вам интересно, обязательно прочитайте текущий статус этой функции в моей статье .

Закрытие

Чтобы быть в курсе этой и других функций, обязательно следите за нашим трекером API Fugu . Мы очень рады продвинуть Интернет вперед и позволить вам делать больше на платформе. Выражаем благодарность за постоянно совершенствующийся Excalidraw и за все замечательные приложения, которые вы создадите. Начните творить на excalidraw.com .

Мне не терпится увидеть, как некоторые API, которые я показал сегодня, появятся в ваших приложениях. Меня зовут Том, вы можете найти меня как @tomayac в Твиттере и в Интернете в целом. Большое спасибо за просмотр и приятного отдыха в Google I/O.