LogoMaker / index.js
SenY's picture
Hueを実装
8f9ad04
import { applyEffect, getAvailableEffects } from './effects.js';
import { InvertPostProcess } from './postprocess/invert.js';
import { HuePostProcess } from './postprocess/hue.js';
import { BasePostProcess } from './postprocess/base.js';
const tagDisplayNames = {
japanese: "日本語",
english: "英語",
kanji: "漢字対応",
business: "ビジネス",
fancy: "装飾的",
playful: "遊び心",
display: "ディスプレイ",
handwritten: "手書き",
retro: "レトロ",
calm: "落ち着いた",
cute: "かわいい",
script: "筆記体",
bold: "太字",
horror: "ホラー",
comic: "コミック"
};
const fontTags = [
// 日本語フォント
{ name: "Aoboshi One", tags: ["japanese"] },
{ name: "BIZ UDGothic", tags: ["japanese", "kanji", "business"] },
{ name: "BIZ UDMincho", tags: ["japanese", "kanji", "business"] },
{ name: "BIZ UDPGothic", tags: ["japanese", "kanji", "business"] },
{ name: "BIZ UDPMincho", tags: ["japanese", "kanji", "business"] },
{ name: "Cherry Bomb One", tags: ["japanese", "cute"] },
{ name: "Chokokutai", tags: ["japanese", "fancy"] },
{ name: "Darumadrop One", tags: ["japanese", "playful"] },
{ name: "Dela Gothic One", tags: ["japanese", "kanji", "display"] },
{ name: "DotGothic16", tags: ["japanese", "kanji", "retro"] },
{ name: "Hachi Maru Pop", tags: ["japanese", "kanji", "cute"] },
{ name: "Hina Mincho", tags: ["japanese", "kanji", "fancy"] },
{ name: "IBM Plex Sans JP", tags: ["japanese", "kanji", "business"] },
{ name: "Kaisei Decol", tags: ["japanese", "kanji", "fancy"] },
{ name: "Kaisei HarunoUmi", tags: ["japanese", "kanji", "fancy"] },
{ name: "Kaisei Opti", tags: ["japanese", "kanji", "business"] },
{ name: "Kaisei Tokumin", tags: ["japanese", "kanji", "business"] },
{ name: "Kiwi Maru", tags: ["japanese", "kanji", "cute"] },
{ name: "Klee One", tags: ["japanese", "kanji", "handwritten"] },
{ name: "Kosugi", tags: ["japanese", "kanji", "business"] },
{ name: "Kosugi Maru", tags: ["japanese", "kanji", "calm"] },
{ name: "M PLUS 1", tags: ["japanese", "kanji", "business"] },
{ name: "M PLUS 1 Code", tags: ["japanese", "kanji", "display"] },
{ name: "M PLUS 1p", tags: ["japanese", "kanji", "business"] },
{ name: "M PLUS 2", tags: ["japanese", "kanji", "business"] },
{ name: "M PLUS Rounded 1c", tags: ["japanese", "kanji", "calm"] },
{ name: "Mochiy Pop One", tags: ["japanese", "kanji", "playful"] },
{ name: "Mochiy Pop P One", tags: ["japanese", "kanji", "playful"] },
{ name: "Monomaniac One", tags: ["japanese", "display"] },
{ name: "Murecho", tags: ["japanese", "business"] },
{ name: "New Tegomin", tags: ["japanese", "kanji", "fancy"] },
{ name: "Noto Sans JP", tags: ["japanese", "kanji", "business"] },
{ name: "Noto Serif JP", tags: ["japanese", "kanji", "business"] },
{ name: "Palette Mosaic", tags: ["japanese", "display"] },
{ name: "Potta One", tags: ["japanese", "kanji", "playful"] },
{ name: "Rampart One", tags: ["japanese", "kanji", "display"] },
{ name: "Reggae One", tags: ["japanese", "kanji", "display"] },
{ name: "Rock 3D", tags: ["japanese", "display"] },
{ name: "RocknRoll One", tags: ["japanese", "kanji", "playful"] },
{ name: "Sawarabi Gothic", tags: ["japanese", "kanji", "business"] },
{ name: "Sawarabi Mincho", tags: ["japanese", "kanji", "business"] },
{ name: "Shippori Antique", tags: ["japanese", "kanji", "retro"] },
{ name: "Shippori Antique B1", tags: ["japanese", "kanji", "retro"] },
{ name: "Shippori Mincho", tags: ["japanese", "kanji", "business"] },
{ name: "Shippori Mincho B1", tags: ["japanese", "kanji", "business"] },
{ name: "Shizuru", tags: ["japanese", "display"] },
{ name: "Slackside One", tags: ["japanese", "handwritten"] },
{ name: "Stick", tags: ["japanese", "kanji", "display"] },
{ name: "Train One", tags: ["japanese", "kanji", "display"] },
{ name: "Tsukimi Rounded", tags: ["japanese", "calm"] },
{ name: "Yomogi", tags: ["japanese", "kanji", "handwritten"] },
{ name: "Yuji Boku", tags: ["japanese", "kanji", "fancy"] },
{ name: "Yuji Hentaigana Akari", tags: ["japanese", "fancy"] },
{ name: "Yuji Hentaigana Akebono", tags: ["japanese", "fancy"] },
{ name: "Yuji Mai", tags: ["japanese", "kanji", "fancy"] },
{ name: "Yuji Syuku", tags: ["japanese", "kanji", "fancy"] },
{ name: "Yusei Magic", tags: ["japanese", "kanji", "playful"] },
{ name: "Zen Antique", tags: ["japanese", "kanji", "retro"] },
{ name: "Zen Antique Soft", tags: ["japanese", "kanji", "retro"] },
{ name: "Zen Kaku Gothic Antique", tags: ["japanese", "kanji", "business"] },
{ name: "Zen Kaku Gothic New", tags: ["japanese", "kanji", "business"] },
{ name: "Zen Kurenaido", tags: ["japanese", "calm"] },
{ name: "Zen Maru Gothic", tags: ["japanese", "calm"] },
{ name: "Zen Old Mincho", tags: ["japanese", "kanji", "retro"] },
// 英語フォント - ビジネス/フォーマル
{ name: "Montserrat", tags: ["english", "business"] },
{ name: "Playfair Display", tags: ["english", "business", "fancy"] },
{ name: "Roboto", tags: ["english", "business"] },
{ name: "Lato", tags: ["english", "business"] },
{ name: "Poppins", tags: ["english", "business", "calm"] },
{ name: "Quicksand", tags: ["english", "calm"] },
{ name: "Raleway", tags: ["english", "calm"] },
// デコラティブ/ファンシー
{ name: "Pacifico", tags: ["english", "fancy", "script"] },
{ name: "Great Vibes", tags: ["english", "fancy", "script"] },
{ name: "Lobster", tags: ["english", "fancy"] },
{ name: "Dancing Script", tags: ["english", "fancy", "script"] },
{ name: "Satisfy", tags: ["english", "fancy", "script"] },
{ name: "Courgette", tags: ["english", "fancy", "script"] },
{ name: "Kaushan Script", tags: ["english", "fancy", "script"] },
{ name: "Sacramento", tags: ["english", "fancy", "script", "handwritten"] },
// かわいい/プレイフル
{ name: "Bubblegum Sans", tags: ["english", "display", "cute", "playful"] },
{ name: "Comic Neue", tags: ["english", "comic", "cute", "handwritten"] },
{ name: "Sniglet", tags: ["english", "display", "cute", "playful"] },
{ name: "Patrick Hand", tags: ["english", "handwritten", "playful"] },
{ name: "Indie Flower", tags: ["english", "handwritten", "playful"] },
// 手書き/筆記体
{ name: "Caveat", tags: ["english", "handwritten", "script"] },
{ name: "Shadows Into Light", tags: ["english", "handwritten"] },
{ name: "Architects Daughter", tags: ["english", "handwritten"] },
{ name: "Covered By Your Grace", tags: ["english", "handwritten"] },
{ name: "Just Another Hand", tags: ["english", "handwritten"] },
// 太字/ディスプレイ
{ name: "Righteous", tags: ["english", "display"] },
{ name: "Permanent Marker", tags: ["english", "display", "handwritten"] },
{ name: "Press Start 2P", tags: ["english", "display", "retro"] },
{ name: "Fredoka One", tags: ["english", "display", "playful"] },
{ name: "Creepster", tags: ["english", "display", "horror"] },
{ name: "Bangers", tags: ["english", "display", "comic"] },
{ name: "Rubik Mono One", tags: ["english", "display", "bold"] },
{ name: "Bungee", tags: ["english", "display", "bold"] },
{ name: "Bungee Shade", tags: ["english", "display", "fancy"] },
{ name: "Monoton", tags: ["english", "display", "retro"] },
{ name: "Anton", tags: ["english", "display", "bold"] },
{ name: "Bebas Neue", tags: ["english", "display", "bold"] },
{ name: "Black Ops One", tags: ["english", "display", "bold"] },
{ name: "Bowlby One SC", tags: ["english", "display", "bold"] }
];
// フォントの読み込みを管理する関数
async function loadGoogleFont(fontFamily) {
// フォントファミリー名を正しく整形
const formattedFamily = fontFamily.replace(/ /g, '+');
// Google Fonts APIのURLを構築
const url = `https://fonts.googleapis.com/css2?family=${formattedFamily}&display=swap`;
// 既存のリンクタグがあれば削除
const existingLink = document.querySelector(`link[href*="${formattedFamily}"]`);
if (existingLink) {
existingLink.remove();
}
// 新しいリンクタグを追加
const link = document.createElement('link');
link.href = url;
link.rel = 'stylesheet';
document.head.appendChild(link);
// フォントの読み込みを待つ
await new Promise((resolve, reject) => {
link.onload = async () => {
try {
// フォントの読み込みを確認
await document.fonts.load(`16px "${fontFamily}"`);
// 少し待機して確実にフォントを利用可能にする
setTimeout(resolve, 100);
} catch (error) {
reject(error);
}
};
link.onerror = reject;
});
}
// テキストを画像に変換する関数を更新
async function textToImage(text, fontFamily, fontSize = '48px', effectType = 'simple') {
console.debug(`テキスト描画開始: ${effectType}`, { text, fontFamily, fontSize });
try {
await document.fonts.load(`${fontSize} "${fontFamily}"`);
const fontSizeNum = parseInt(fontSize);
const verticalText = document.getElementById('verticalText').checked;
const verticalSpacing = document.getElementById('verticalSpacing').value;
// エフェクトを適用してcanvasを取得
const canvas = await applyEffect(effectType, text, {
font: fontFamily,
fontSize: fontSizeNum,
vertical: verticalText,
verticalSpacing: verticalSpacing
});
// ポストプロセスを適用
const processedCanvas = await applyPostProcessors(canvas);
// PNG化して返す
return BasePostProcess.toPng(processedCanvas);
} catch (error) {
console.error('フォント描画エラー:', error);
throw error;
}
}
// デバウンス関数の実装
let renderTimeout = null;
let isRendering = false;
function debounceRender(callback, delay = 200) {
if (renderTimeout) {
clearTimeout(renderTimeout);
}
if (isRendering) {
return;
}
renderTimeout = setTimeout(async () => {
isRendering = true;
try {
await callback();
} finally {
isRendering = false;
}
}, delay);
}
// ポストプロセス処理のインスタンスを作成
const postProcessors = {
invert: new InvertPostProcess(),
hue: new HuePostProcess()
};
/**
* ポストプロセスのカードを生成
*/
function createPostProcessCard(processor) {
const div = document.createElement('div');
div.className = 'col-md-6 col-lg-4';
div.innerHTML = `
<div class="card h-100">
<div class="card-header d-flex align-items-center">
<div class="form-check mb-0">
<input type="checkbox" class="form-check-input" name="postProcess" id="postProcess${processor.name}" value="${processor.name}">
<label class="form-check-label" for="postProcess${processor.name}">
${processor.label}
</label>
</div>
</div>
<div class="card-body">
${processor.ui.template}
</div>
</div>
`;
return div;
}
/**
* ポストプロセスのUIを初期化
*/
function initializePostProcessUI() {
const container = document.getElementById('postProcessContainer');
container.innerHTML = '';
// 各プロセッサのカードを生成
Object.values(postProcessors).forEach(processor => {
const card = createPostProcessCard(processor);
container.appendChild(card);
});
// スライダーの値変更時のイベントハンドラを設定
const hueRotate = document.getElementById('hueRotate');
if (hueRotate) {
hueRotate.addEventListener('input', (e) => {
document.getElementById('hueRotateValue').textContent = e.target.value;
debounceRender(renderAllPresets);
});
}
}
/**
* 選択されているポストプロセスを取得
*/
function getSelectedPostProcessors() {
const container = document.getElementById('postProcessContainer');
const checkboxes = container.querySelectorAll('input[type="checkbox"]:checked');
return Array.from(checkboxes).map(cb => postProcessors[cb.value]).filter(Boolean);
}
/**
* ポストプロセスを適用
*/
async function applyPostProcessors(canvas) {
let currentCanvas = canvas;
const processors = getSelectedPostProcessors();
for (const processor of processors) {
currentCanvas = await processor.apply(currentCanvas);
}
return currentCanvas;
}
/**
* プレビューを更新
*/
async function updatePreview(effectType) {
const text = document.getElementById('textInput').value;
const font = document.getElementById('googleFontInput').value;
const fontSize = parseInt(document.getElementById('fontSize').value);
const vertical = document.getElementById('verticalText').checked;
const verticalSpacing = document.getElementById('verticalSpacing').value;
try {
// エフェクトタイプが指定されていない場合は更新しない
if (!effectType) return;
// エフェクトを適用してcanvasを取得
const canvas = await applyEffect(effectType, text, {
font,
fontSize,
vertical,
verticalSpacing
});
// ポストプロセスを適用
const processedCanvas = await applyPostProcessors(canvas);
// PNG化してプレビューに表示
const dataUrl = BasePostProcess.toPng(processedCanvas);
const previewImage = document.querySelector(`.effect-item[data-effect="${effectType}"] img`);
if (previewImage) {
previewImage.src = dataUrl;
}
} catch (error) {
console.error('プレビューの更新に失敗しました:', error);
}
}
// すべてのプリセットを描画する関数
async function renderAllPresets() {
const effectGrid = document.querySelector('.effect-grid');
const textInput = document.getElementById('textInput');
const fontSelect = document.getElementById('googleFontInput');
const fontSizeInput = document.getElementById('fontSize');
effectGrid.innerHTML = '';
const text = textInput.value || 'プレビュー';
const fontFamily = fontSelect.value;
const fontSize = fontSizeInput.value + 'px';
const effects = getAvailableEffects();
for (const effect of effects) {
try {
const imageUrl = await textToImage(text, fontFamily, fontSize, effect.name);
const presetCard = document.createElement('div');
presetCard.className = 'effect-item';
presetCard.dataset.effect = effect.name;
presetCard.innerHTML = `
<div class="effect-name">${effect.name}</div>
<div class="preview-container">
<img src="${imageUrl}" alt="${effect.name}">
</div>
`;
effectGrid.appendChild(presetCard);
} catch (error) {
console.error(`プリセット ${effect.name} の描画エラー:`, error);
const errorCard = document.createElement('div');
errorCard.className = 'effect-item error';
errorCard.dataset.effect = effect.name;
errorCard.innerHTML = `
<div class="effect-name text-danger">${effect.name}</div>
<div class="preview-container">
<div class="text-danger">
<small>エラー: ${error.message}</small>
</div>
</div>
`;
effectGrid.appendChild(errorCard);
}
}
}
// イベントリスナーを追加(ポストプロセスの変更時にすべてのプレビューを更新)
document.getElementById('postProcessContainer').addEventListener('change', () => {
renderAllPresets();
});
// イベントリスナーの設定を更新
document.addEventListener('DOMContentLoaded', async () => {
const fontSelect = document.getElementById('googleFontInput');
const fontTagFilter = document.getElementById('fontTagFilter');
const textInput = document.getElementById('textInput');
const fontSizeInput = document.getElementById('fontSize');
const verticalTextInput = document.getElementById('verticalText');
const effectGrid = document.querySelector('.effect-grid');
const noFontsMessage = document.getElementById('noFontsMessage');
// 利用可能なタグを収集し、使用頻度をカウント
function getTagsWithCount() {
const tagCount = new Map();
fontTags.forEach(font => {
font.tags.forEach(tag => {
tagCount.set(tag, (tagCount.get(tag) || 0) + 1);
});
});
return tagCount;
}
// 言語関連のタグかどうかを判定
function isLanguageTag(tag) {
return ['japanese', 'english', 'kanji'].includes(tag);
}
// フィルターボタンを動的に生成
function createFilterButtons() {
const tagCount = getTagsWithCount();
fontTagFilter.innerHTML = '';
// タグを言語関連とその他に分類し、個数でソート
const languageTags = [...tagCount.entries()]
.filter(([tag]) => isLanguageTag(tag))
.sort((a, b) => b[1] - a[1]);
const otherTags = [...tagCount.entries()]
.filter(([tag]) => !isLanguageTag(tag))
.sort((a, b) => b[1] - a[1]);
// 言語関連のタグを追加
if (languageTags.length > 0) {
const langGroup = document.createElement('div');
langGroup.className = 'filter-group mb-2';
langGroup.innerHTML = '<div class="filter-group-label mb-1">言語</div>';
const langButtonGroup = document.createElement('div');
langButtonGroup.className = 'btn-group-wrapper';
languageTags.forEach(([tag, count]) => {
const displayName = tagDisplayNames[tag] || tag;
const button = document.createElement('div');
button.className = 'btn-check-wrapper';
button.innerHTML = `
<input type="checkbox" class="btn-check" name="fontFilter" id="filter${tag}" value="${tag}">
<label class="btn btn-outline-primary" for="filter${tag}">
${displayName} <span class="tag-count" data-tag="${tag}">(${count})</span>
</label>
`;
langButtonGroup.appendChild(button);
});
langGroup.appendChild(langButtonGroup);
fontTagFilter.appendChild(langGroup);
}
// その他のタグを追加
if (otherTags.length > 0) {
const otherGroup = document.createElement('div');
otherGroup.className = 'filter-group';
otherGroup.innerHTML = '<div class="filter-group-label mb-1">スタイル</div>';
const otherButtonGroup = document.createElement('div');
otherButtonGroup.className = 'btn-group-wrapper';
otherTags.forEach(([tag, count]) => {
const displayName = tagDisplayNames[tag] || tag;
const button = document.createElement('div');
button.className = 'btn-check-wrapper';
button.innerHTML = `
<input type="checkbox" class="btn-check" name="fontFilter" id="filter${tag}" value="${tag}">
<label class="btn btn-outline-primary" for="filter${tag}">
${displayName} <span class="tag-count" data-tag="${tag}">(${count})</span>
</label>
`;
otherButtonGroup.appendChild(button);
});
otherGroup.appendChild(otherButtonGroup);
fontTagFilter.appendChild(otherGroup);
}
}
// 選択されたフィルターに基づいてタグカウントを更新
function updateTagCounts() {
const selectedFilters = Array.from(fontTagFilter.querySelectorAll('input[type="checkbox"]:checked'))
.map(checkbox => checkbox.value);
// 選択されたフィルターがない場合は、全体の数を表示し、すべてのボタンを表示
if (selectedFilters.length === 0) {
const totalCounts = getTagsWithCount();
totalCounts.forEach((count, tag) => {
const wrapper = document.querySelector(`#filter${tag}`).closest('.btn-check-wrapper');
wrapper.style.display = 'inline-block';
const countElement = document.querySelector(`.tag-count[data-tag="${tag}"]`);
if (countElement) {
countElement.textContent = `(${count})`;
}
});
return;
}
// 各タグについて、現在の選択と組み合わせた場合の数を計算
const allTags = [...new Set(fontTags.flatMap(font => font.tags))];
allTags.forEach(tag => {
const countElement = document.querySelector(`.tag-count[data-tag="${tag}"]`);
const wrapper = document.querySelector(`#filter${tag}`).closest('.btn-check-wrapper');
if (countElement && wrapper) {
// このタグが既に選択されている場合は、他の選択されたタグとの組み合わせ数を表示
const filtersToCheck = selectedFilters.includes(tag)
? selectedFilters
: [...selectedFilters, tag];
const count = fontTags.filter(font =>
filtersToCheck.every(filter => font.tags.includes(filter))
).length;
countElement.textContent = `(${count})`;
// カウントが0の場合、かつ現在選択されていないタグの場合は非表示
if (count === 0 && !selectedFilters.includes(tag)) {
wrapper.style.display = 'none';
} else {
wrapper.style.display = 'inline-block';
}
}
});
}
// フォントオプションの初期化と絞り込み機能の実装
function initializeFontOptions() {
// 現在選択されているフォントを保持
const currentFont = fontSelect.value;
// 選択されているフィルターを取得
const selectedFilters = Array.from(fontTagFilter.querySelectorAll('input[type="checkbox"]:checked'))
.map(checkbox => checkbox.value);
// 既存のオプションをクリア
fontSelect.innerHTML = '';
// フィルタリングされたフォントのリストを作成
const filteredFonts = selectedFilters.length === 0
? fontTags
: fontTags.filter(font =>
selectedFilters.every(filter => font.tags.includes(filter))
);
// フォルター結果が0件の場合
if (filteredFonts.length === 0) {
noFontsMessage.style.display = 'block';
fontSelect.disabled = true;
return Promise.resolve(); // フォントの読み込みは不要
} else {
noFontsMessage.style.display = 'none';
fontSelect.disabled = false;
}
// フォントオプションを追加
filteredFonts.forEach((font, index) => {
const option = document.createElement('option');
option.value = font.name;
option.textContent = font.name;
// 以前選択されていたフォントがある場合はそれを選択、なければ最初のフォントを選択
if (font.name === currentFont || (index === 0 && !currentFont)) {
option.selected = true;
}
fontSelect.appendChild(option);
});
// タグカウントを更新
updateTagCounts();
// 選択されたフォントを読み込む
return loadGoogleFont(fontSelect.value);
}
// タグフィルターの変更イベントを設定
fontTagFilter.addEventListener('change', async (e) => {
if (e.target.type === 'checkbox') {
await initializeFontOptions();
if (!fontSelect.disabled) { // フォントが選択可能な場合のみプレビューを更新
await renderAllPresets();
}
}
});
// 初期化
createFilterButtons();
await initializeFontOptions();
await loadGoogleFont(fontSelect.value);
// 縦書きモードの状態をグリッドに反映
verticalTextInput.addEventListener('change', (e) => {
effectGrid.dataset.vertical = e.target.checked;
renderAllPresets();
});
// フォント変更時の処理
fontSelect.addEventListener('change', async (e) => {
try {
const fontFamily = e.target.value;
await loadGoogleFont(fontFamily);
await renderAllPresets();
} catch (error) {
console.error('フォント読み込みエラー:', error);
}
});
// テキストとフォントサイズの変更時にすべてのプリセットを再描画
[textInput, fontSizeInput, verticalTextInput, verticalSpacing].forEach(element => {
element.addEventListener('input', () => {
debounceRender(renderAllPresets);
});
});
// 初期描画
await renderAllPresets();
// ポストプロセスのUI初期化
initializePostProcessUI();
});
// 縦書きモードの切り替え時の処理
document.getElementById('verticalText').addEventListener('change', function (e) {
const spacingContainer = document.getElementById('verticalSpacingContainer');
spacingContainer.style.display = e.target.checked ? 'block' : 'none';
// ロゴの再生成処理を呼び出す
});
// 文字間隔の変更時の処理
document.getElementById('verticalSpacing').addEventListener('input', function (e) {
document.getElementById('verticalSpacingValue').textContent = e.target.value;
// ロゴの再生成処理を呼び出す
});