Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions app/Http/Controllers/Core/PreviewController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

namespace App\Http\Controllers\Core;

use App\Http\Controllers\Controller;
use App\Models\Common\Page;
use App\Utilities\Preview\PreviewDevice;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

class PreviewController extends Controller
{
/** プレビュー画面はプラグイン管理者だけが利用できる */
public function __construct()
{
$this->middleware(['auth', 'can:role_arrangement'])->only('show');
}

/** 不正なプレビューURLを404として扱う */
public function notFound()
{
abort(404);
}

/** 選択した画面サイズで対象ページを表示するプレビュー枠を返す */
public function show(Request $request, $page_id)
{
if (!is_scalar($page_id)) {
abort(404);
}

$page_id = filter_var($page_id, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
if ($page_id === false) {
abort(404);
}

$page = Page::find($page_id);
if (empty($page)) {
abort(404);
}

if (!$this->isPageRoute($page)) {
abort(404);
}

$page_tree = $page->getPageTreeByGoingBackParent(null);
if (!$page->isVisibleAncestorsAndSelf($page_tree)) {
abort(403);
}

$device = PreviewDevice::normalize($request->query('preview_device'));

return view('core.preview_device', [
'device' => $device,
'devices' => PreviewDevice::devices(),
'frame_url' => PreviewDevice::frameUrl($page->permanent_link),
'page_url' => $page->permanent_link,
]);
}

/**
* DBから取得した固定URLが公開ページとして解決されるか判定する。
* 外部URLやクエリ文字列などを含まないパスであることを確認したうえで、
* Laravelのルーターで通常ページ表示用のget_allルートに一致する場合だけ許可する。
*/
private function isPageRoute(Page $page): bool
{
$permanent_link = $page->permanent_link;
if (!is_string($permanent_link) || strpos($permanent_link, '/') !== 0 ||
strpos($permanent_link, '//') === 0 || preg_match('/[\\x00-\\x1F\\x7F\\\\]/', $permanent_link)) {
return false;
}

$parts = parse_url($permanent_link);
if ($parts === false || isset($parts['scheme']) || isset($parts['host']) || isset($parts['port']) ||
isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment'])) {
return false;
}

$page_request = Request::create($permanent_link, 'GET');

return Route::getRoutes()->match($page_request)->getName() === 'get_all';
}
}
66 changes: 66 additions & 0 deletions app/Utilities/Preview/PreviewDevice.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

namespace App\Utilities\Preview;

class PreviewDevice
{
/** 現在のブラウザサイズを利用する選択値 */
public const CURRENT = 'current';

/** プレビューで選択できる画面サイズ */
private const DEVICES = [
self::CURRENT => [
'label' => '現在の画面',
'width' => null,
'height' => null,
],
'pc' => [
'label' => 'PC',
'width' => 1200,
'height' => 800,
],
'tablet' => [
'label' => 'タブレット',
'width' => 768,
'height' => 1024,
],
'smartphone' => [
'label' => 'スマホ',
'width' => 390,
'height' => 844,
],
];

/** 未指定または不正な画面サイズを現在の画面として扱う */
public static function normalize(?string $device): string
{
return array_key_exists($device, self::DEVICES) ? $device : self::CURRENT;
}

/** 画面サイズ選択肢を返す */
public static function devices(): array
{
return self::DEVICES;
}

/** 選択した画面サイズの表示名を返す */
public static function label(?string $device): string
{
return self::DEVICES[self::normalize($device)]['label'];
}

/** プレビュー対象URLへiframe内表示用のクエリを付ける */
public static function frameUrl(string $permanent_link): string
{
return self::appendQuery($permanent_link, [
'mode' => 'preview',
'preview_frame' => '1',
]);
}

/** クエリがある場合だけURLへ付与する */
private static function appendQuery(string $path, array $query): string
{
return empty($query) ? $path : $path . '?' . http_build_query($query);
}
}
2 changes: 1 addition & 1 deletion public/css/app.css

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions public/js/preview.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion public/mix-manifest.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
{
"/js/app.js": "/js/app.js?id=0a671338cbb77a4f2687858912039112",
"/js/preview.js": "/js/preview.js?id=8a8863d0a3b23294ae4b34f04fa14fac",
"/js/wysiwyg.js": "/js/wysiwyg.js?id=61016a2c0d95940940b7a33f9c02c3f5",
"/js/codemirror.js": "/js/codemirror.js?id=1648b8c7831f2474820107a74359317f",
"/js/manage/page/index.js": "/js/manage/page/index.js?id=fcc08083204f8beb01ffa00fe8623f2e",
"/css/app.css": "/css/app.css?id=d6fb0b661b1f484b915a1dd7c5d5f57c",
"/css/app.css": "/css/app.css?id=3b2d3697a45d4ca4399b6c30ff0f5a48",
"/fonts/vendor/@fortawesome/fontawesome-free/webfa-solid-900.woff2?2463b90d9a316e4e5294e6706b6a7a72": "/fonts/vendor/@fortawesome/fontawesome-free/webfa-solid-900.woff2?2463b90d9a316e4e5294e6706b6a7a72?id=109ad919b74a62a8a223361da1651bbc",
"/fonts/vendor/@fortawesome/fontawesome-free/webfa-solid-900.ttf?2582b0e4bcf85eceead0de3fd9035c90": "/fonts/vendor/@fortawesome/fontawesome-free/webfa-solid-900.ttf?2582b0e4bcf85eceead0de3fd9035c90?id=8218477819a31609afa351933eebcdb4",
"/fonts/vendor/@fortawesome/fontawesome-free/webfa-regular-400.woff2?89999bdf5d835c0120257222e5568fec": "/fonts/vendor/@fortawesome/fontawesome-free/webfa-regular-400.woff2?89999bdf5d835c0120257222e5568fec?id=25bde24dc00e5dce8f1bfa8164b3b7b1",
Expand Down
124 changes: 124 additions & 0 deletions resources/js/preview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
'use strict';

document.addEventListener('DOMContentLoaded', function () {
const preview = document.querySelector('[data-preview-device]');
if (!preview) {
return;
}

const stage = preview;
const canvas = preview.querySelector('.cc-preview-canvas');
const frame = preview.querySelector('.cc-preview-frame');
const sizeLabel = document.querySelector('.cc-preview-size-label');
const deviceButtons = Array.from(document.querySelectorAll('[data-preview-device-option]'));
const scaleButtons = Array.from(document.querySelectorAll('[data-preview-scale-option]'));
let selectedDevice = preview.dataset.selectedDevice || 'current';
let fitToScreen = true;
let resizeFrame = null;
let stageResizeObserver = null;

/** 現在の画面または固定プリセットの論理寸法を返す。 */
function getDimensions() {
const selectedButton = deviceButtons.find(function (button) {
return button.dataset.previewDeviceOption === selectedDevice;
});

if (!selectedButton || selectedDevice === 'current') {
return {
width: document.documentElement.clientWidth,
height: window.innerHeight,
label: selectedButton ? selectedButton.dataset.previewDeviceLabel : '現在の画面',
};
}

return {
width: Number(selectedButton.dataset.previewWidth),
height: Number(selectedButton.dataset.previewHeight),
label: selectedButton.dataset.previewDeviceLabel,
};
}

/** 選択状態をURLへ反映し、再読み込み時に固定プリセットを復元できるようにする。 */
function updateUrl() {
const url = new URL(window.location.href);
if (selectedDevice === 'current') {
url.searchParams.delete('preview_device');
} else {
url.searchParams.set('preview_device', selectedDevice);
}
window.history.replaceState({}, '', url.toString());
}

/** ボタンの見た目と支援技術向けの選択状態を更新する。 */
function updateButtons() {
deviceButtons.forEach(function (button) {
const selected = button.dataset.previewDeviceOption === selectedDevice;
button.classList.toggle('btn-primary', selected);
button.classList.toggle('btn-light', !selected);
button.setAttribute('aria-pressed', selected ? 'true' : 'false');
});

scaleButtons.forEach(function (button) {
const selected = button.dataset.previewScaleOption === (fitToScreen ? 'fit' : 'actual');
button.classList.toggle('btn-primary', selected);
button.classList.toggle('btn-light', !selected);
button.setAttribute('aria-pressed', selected ? 'true' : 'false');
});
}

/** iframeの論理寸法を保ったまま、必要に応じて表示だけを縮小する。 */
function applyPreviewSize(updateHistory) {
const dimensions = getDimensions();
const availableWidth = stage.clientWidth;
const scale = fitToScreen ? Math.min(1, availableWidth / dimensions.width) : 1;

frame.style.width = dimensions.width + 'px';
frame.style.height = dimensions.height + 'px';
frame.style.transform = 'scale(' + scale + ')';
frame.title = dimensions.label + 'のページプレビュー';
sizeLabel.textContent = dimensions.width + ' × ' + dimensions.height;

canvas.style.width = Math.round(dimensions.width * scale) + 'px';
canvas.style.height = Math.round(dimensions.height * scale) + 'px';

updateButtons();
preview.classList.add('cc-preview-stage--ready');

if (updateHistory) {
updateUrl();
}
}

deviceButtons.forEach(function (button) {
button.addEventListener('click', function () {
selectedDevice = button.dataset.previewDeviceOption;
applyPreviewSize(true);
});
});

scaleButtons.forEach(function (button) {
button.addEventListener('click', function () {
fitToScreen = button.dataset.previewScaleOption === 'fit';
applyPreviewSize(false);
});
});

window.addEventListener('resize', function () {
if (resizeFrame !== null) {
window.cancelAnimationFrame(resizeFrame);
}
resizeFrame = window.requestAnimationFrame(function () {
applyPreviewSize(false);
resizeFrame = null;
});
});

if (window.ResizeObserver) {
stageResizeObserver = new window.ResizeObserver(function () {
applyPreviewSize(false);
});
stageResizeObserver.observe(stage);
}

applyPreviewSize(false);
});
1 change: 1 addition & 0 deletions resources/sass/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
@import 'layout/left';
@import 'layout/right';
@import 'layout/tables';
@import 'layout/preview';

// Manage styles
@import 'manage/page';
Expand Down
84 changes: 84 additions & 0 deletions resources/sass/layout/_preview.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
.cc-preview-shell {
min-height: 100vh;
margin: 0;
background: #d9dde2;
}

.cc-preview-toolbar {
position: sticky;
top: 0;
z-index: $zindex-fixed + 1;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
padding: .5rem;
color: $white;
background: $dark;
gap: .5rem;
}

.cc-preview-toolbar__group {
flex-shrink: 0;
}

.cc-preview-toolbar__help {
flex-basis: 100%;
text-align: center;
font-size: $font-size-sm;
}

.cc-preview-stage {
width: 100%;
min-height: calc(100vh - 5rem);
overflow: auto;
}

.cc-preview-canvas {
position: relative;
margin: 0 auto 1rem;
}

.cc-preview-frame {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
border: 0;
background: $white;
box-shadow: 0 0 0 1px rgba($black, .2), 0 .25rem 1rem rgba($black, .2);
transform-origin: top left;
visibility: hidden;
}

.cc-preview-size-label {
width: fit-content;
padding: .125rem .5rem;
border: 1px solid rgba($black, .2);
border-radius: $border-radius-sm;
background: $white;
color: $dark;
font-size: $font-size-sm;
line-height: 1.5;
}

.cc-preview-stage--ready .cc-preview-frame {
visibility: visible;
}

@include media-breakpoint-down(sm) {
.cc-preview-toolbar {
justify-content: flex-start;
}

.cc-preview-toolbar__help {
width: 100%;
text-align: center;
}

.cc-preview-toolbar__exit {
margin-left: auto;
margin-right: auto;
}
}
Loading
Loading